Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d434b9ca8 | |||
| ce9d705df0 | |||
| b309368225 | |||
| 1373479973 | |||
| 23b04bd343 | |||
| 36b4c4c60f | |||
| 34d35e8f04 | |||
| 2d7625a751 | |||
| 3eebc1c358 | |||
| b320fee622 | |||
| 67ced8b3f8 | |||
| 0b4b4441c0 | |||
| c8dff253cc | |||
| 77187e76ce | |||
| 84cc47e3cf | |||
| 8d29dbd126 | |||
| 94ab94eceb | |||
| 74f456ef7d | |||
| 7720b6b404 | |||
| 0b982a5ff4 | |||
| 5571dcc511 | |||
| acdf298811 | |||
| 8cbcc22a74 | |||
| 31435c1e97 | |||
| e35b0985ee | |||
| 002c097def |
@@ -0,0 +1,34 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- skip-changelog
|
||||
- ignore-for-release
|
||||
categories:
|
||||
- title: 🚨 破坏性变更
|
||||
labels:
|
||||
- breaking-change
|
||||
- breaking change
|
||||
- title: 🔒 安全更新
|
||||
labels:
|
||||
- security
|
||||
- title: ✨ 新功能
|
||||
labels:
|
||||
- feature
|
||||
- enhancement
|
||||
- title: 🐛 问题修复
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: 📚 文档
|
||||
labels:
|
||||
- documentation
|
||||
- docs
|
||||
- title: 🧰 工程维护
|
||||
labels:
|
||||
- chore
|
||||
- ci
|
||||
- dependencies
|
||||
- refactor
|
||||
- title: 其他更新
|
||||
labels:
|
||||
- "*"
|
||||
@@ -5,18 +5,42 @@ on:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ai-pr-review-${{ github.event.pull_request.number }}
|
||||
group: ai-pr-review-${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ai-review:
|
||||
name: AI PR Review
|
||||
if: >
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
(
|
||||
contains(github.event.comment.body, '/ai-review') ||
|
||||
contains(github.event.comment.body, '@ai-reviewer review')
|
||||
) &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -50,8 +74,8 @@ jobs:
|
||||
OPENAI_RETRIES: ${{ vars.OPENAI_RETRIES || '2' }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
|
||||
REVIEW_RULES: .github/ai-review.md,AGENTS.md
|
||||
REVIEW_IGNORE: .ai-reviewignore
|
||||
REVIEW_SEVERITY_THRESHOLD: P3
|
||||
|
||||
@@ -6,8 +6,10 @@ on:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: read
|
||||
issues: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -161,3 +163,94 @@ jobs:
|
||||
VITE_RELEASE_URL=${{ needs.release.outputs.release_url }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
github-release:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- release
|
||||
- docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
version="${{ needs.release.outputs.version }}"
|
||||
repo="${GITHUB_REPOSITORY}"
|
||||
repo_url="https://github.com/${repo}"
|
||||
image_base="${REGISTRY}/${repo}"
|
||||
image_base="${image_base,,}"
|
||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
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
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# LanQin Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
|
||||
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
|
||||
|
||||
## 使用文档
|
||||
|
||||
- [项目文档](${repo_url}#readme)
|
||||
- [开源协议](${repo_url}/blob/main/LICENSE)
|
||||
|
||||
## Docker 镜像
|
||||
|
||||
| 组件 | 镜像 |
|
||||
|------|------|
|
||||
| All-in-one | \`${image_base}:${tag}\` |
|
||||
| API | \`${image_base}-api:${tag}\` |
|
||||
| Web | \`${image_base}-web:${tag}\` |
|
||||
| Postfix | \`${image_base}-postfix:${tag}\` |
|
||||
| Dovecot | \`${image_base}-dovecot:${tag}\` |
|
||||
| Rspamd | \`${image_base}-rspamd:${tag}\` |
|
||||
|
||||
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
|
||||
EOF
|
||||
|
||||
{
|
||||
echo
|
||||
cat generated-release-notes.md
|
||||
} >> release-notes.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
title="LanQin Email ${tag}"
|
||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||
else
|
||||
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
||||
fi
|
||||
|
||||
@@ -1,107 +1,196 @@
|
||||
# LanQin Email
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
自建邮箱 Webmail 全栈方案。前端 React + shadcn/ui,后端 Go + SQLite,默认单容器集成 Postfix、Dovecot、Rspamd。
|
||||
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
||||
|
||||
## 特性
|
||||
## 功能特性
|
||||
|
||||
- **Webmail 客户端** — 文件夹管理、邮件读写、附件、搜索、标签、星标、规则过滤
|
||||
- **多域名/多邮箱** — 域名管理、DKIM 签名、DNS 记录检测、邮箱别名转发
|
||||
- **双因素认证** — TOTP 两步登录(兼容 Google Authenticator / Authy)
|
||||
- **管理员面板** — 用户/域名/邮箱/别名/邮件管理、系统设置、邮件模板
|
||||
- **用户自助** — 开放注册、自助申请邮箱、黑名单、收件规则、联系人
|
||||
- **单容器部署** — 一个容器跑通 API + Web + Nginx + Postfix + Dovecot + Rspamd
|
||||
- **本地投递** — 开发环境系统内邮箱互发直接写入 Inbox,无需公网邮件栈
|
||||
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
|
||||
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
|
||||
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
|
||||
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
|
||||
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
|
||||
- **邮件服务栈**:Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
|
||||
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑
|
||||
├── apps/web # React/Vite Webmail 与管理后台
|
||||
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
|
||||
└── .github/workflows # Docker 镜像发布流水线
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
### 开发环境
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- pnpm 10.28.2(可通过 corepack 启用)
|
||||
|
||||
### 部署环境
|
||||
|
||||
- Docker Engine
|
||||
- Docker Compose v2
|
||||
- 可解析的邮件域名,以及可用的 25 / 465 / 587 / 993 / 995 等端口
|
||||
|
||||
> 公网收发邮件还需要正确配置 MX、SPF、DKIM、DMARC,并确认云厂商未封禁 SMTP 端口。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 开发
|
||||
### 本地开发
|
||||
|
||||
后端:
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd apps/api
|
||||
go mod tidy
|
||||
go mod download
|
||||
go test ./...
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
# 前端(新终端)
|
||||
前端(新终端):
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
默认管理员:`admin@lanqin.local`,密码通过 `LANQIN_ADMIN_PASSWORD` 设置(不设置则启动时随机生成并输出到日志)。
|
||||
访问:
|
||||
|
||||
### 部署
|
||||
- Web:`http://localhost:5173`
|
||||
- API:`http://localhost:8080`
|
||||
|
||||
服务器只需要 Compose 文件和配置,不需要源码构建:
|
||||
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
|
||||
|
||||
### Docker 部署(单容器)
|
||||
|
||||
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# 修改 .env 里的域名和管理员密码
|
||||
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码等
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
单容器内部集成:API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
docker compose logs -f lanqin-email
|
||||
|
||||
# 更新镜像并重启
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# 停止服务
|
||||
docker compose down
|
||||
```
|
||||
|
||||
如需在完整源码仓库中本地构建镜像:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
更多部署细节见 [`deploy/README.md`](./deploy/README.md)。
|
||||
|
||||
## 首次部署清单
|
||||
|
||||
1. 编辑 `deploy/.env`:至少修改 `LANQIN_PUBLIC_HOSTNAME`、`LANQIN_PUBLIC_BASE_URL`、`LANQIN_ADMIN_EMAIL`、`LANQIN_ADMIN_PASSWORD`。
|
||||
2. 生产环境建议挂载真实 TLS 证书,并设置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`。
|
||||
3. 登录管理后台,添加邮件域名。
|
||||
4. 在域名管理中复制并配置 MX、SPF、DKIM、DMARC 记录,然后点击 DNS 检测。
|
||||
5. 创建邮箱账号、别名转发或权限组,按需开启注册、2FA、Turnstile、自助申请邮箱。
|
||||
6. 使用后台 SMTP 测试与 Webmail 收发测试确认链路正常。
|
||||
|
||||
## 关键环境变量
|
||||
|
||||
完整配置见 [`deploy/.env.example`](./deploy/.env.example)。常用变量如下:
|
||||
|
||||
| 变量 | 说明 | 默认/示例 |
|
||||
|------|------|-----------|
|
||||
| `LANQIN_IMAGE` | all-in-one 镜像 | `ghcr.io/lanqin996/lanqin-email:latest` |
|
||||
| `LANQIN_PUBLIC_HOSTNAME` | 邮件服务器主机名,影响 Postfix/DNS 展示/链接 | `mail.example.com` |
|
||||
| `LANQIN_PUBLIC_BASE_URL` | Webmail 对外访问地址 | `https://mail.example.com` |
|
||||
| `LANQIN_ADMIN_EMAIL` | 初始管理员邮箱 | `admin@example.com` |
|
||||
| `LANQIN_ADMIN_PASSWORD` | 初始管理员密码,生产必须修改 | `ChangeMe123!` |
|
||||
| `LANQIN_DB_PATH` | SQLite 数据库路径 | `/data/lanqin.db` |
|
||||
| `LANQIN_ALLOW_INSECURE_HTTP` | 是否允许非 HTTPS Cookie,本地调试可开 | `false` |
|
||||
| `LANQIN_OPEN_REGISTRATION` | 是否开放注册 | `false` |
|
||||
| `LANQIN_TWO_FACTOR_ENABLED` | 2FA 功能总开关 | `false` |
|
||||
| `LANQIN_TURNSTILE_ENABLED` | 是否启用 Turnstile | `false` |
|
||||
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail 发信 SMTP | `127.0.0.1` / `25` |
|
||||
| `LANQIN_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` |
|
||||
| `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` |
|
||||
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` |
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Docker 容器 │
|
||||
│ ┌──────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │
|
||||
│ │ API │ │ Web │ │Nginx │ │ Postfix │ │
|
||||
│ │ Go │ │ React │ │反代 │ │ MTA │ │
|
||||
│ └──┬───┘ └────────┘ └──────┘ └────┬─────┘ │
|
||||
│ │ SQLite Maildir │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ ┌────────┐ ┌──────────┐ │
|
||||
│ │Dovecot │ │ Rspamd │ │
|
||||
│ │ IMAP │ │ 反垃圾 │ │
|
||||
│ └────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ lanqin-email 单容器 │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Nginx │ ───▶ │ Go API │ ───▶ │ SQLite /data │ │
|
||||
│ │ Web 静态│ │ Webmail API│ └──────┬───────┘ │
|
||||
│ └─────────┘ └─────┬──────┘ │ │
|
||||
│ │ Maildir sync │ maps │
|
||||
│ ┌─────────┐ ┌─────▼──────┐ ┌──────▼───────┐ │
|
||||
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │ │
|
||||
│ │ DKIM/AS │ │ SMTP/MTA │ │ IMAP/POP3 │ │
|
||||
│ └─────────┘ └────────────┘ └──────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
数据流:
|
||||
1. **收件** → Postfix 接收 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示
|
||||
2. **发件** → Webmail 编辑 → API 构造 MIME → Postfix 投递
|
||||
3. **反垃圾** → Rspamd 在 Postfix 投递前评分,标记 Spam 文件夹
|
||||
邮件流转:
|
||||
|
||||
## 能力
|
||||
1. **收件**:Postfix 接收邮件 → Rspamd 评分/标记 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示。
|
||||
2. **发件**:Webmail 调用 API → API 构造 MIME → SMTP 提交给 Postfix 或外部 SMTP → 投递到目标地址。
|
||||
3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。
|
||||
4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
|
||||
|
||||
| 模块 | 功能 |
|
||||
|------|------|
|
||||
| 认证 | 登录/注册、会话管理、双因素 TOTP、Turnstile 人机验证 |
|
||||
| 域名 | 多域名管理、DKIM 密钥生成、DNS 记录展示与检测 |
|
||||
| 邮箱 | 邮箱账号管理、容量配额、密码同步 |
|
||||
| Webmail | 文件夹、邮件列表、阅读、写信、附件、搜索、已读/未读、星标、移动、删除、标签 |
|
||||
| 规则 | 收件规则(条件+动作)、发件人黑名单 |
|
||||
| 联系人 | 个人通讯录管理 |
|
||||
| 管理 | 用户/域名/邮箱/别名 CRUD、系统设置持久化、邮件模板编辑、SMTP 测试 |
|
||||
| 清理 | 归档已读、清空回收站/垃圾邮件 |
|
||||
## 开发与验证
|
||||
|
||||
## 收发说明
|
||||
```bash
|
||||
# API 测试
|
||||
cd apps/api
|
||||
go test ./...
|
||||
|
||||
- **开发环境**:系统内邮箱互发直接投递到对方 Inbox。未配置 `LANQIN_SMTP_HOST` 时外部收件人不会真正投递。
|
||||
- **服务器部署**:`.env` 默认 `LANQIN_SMTP_HOST=127.0.0.1`,发件交给同容器内 Postfix。
|
||||
- **收件同步**:Postfix/Dovecot 收到 Maildir 后,API 的 Maildir worker 同步到 SQLite 后展示。
|
||||
- **公网收发**:需要正确配置 MX/SPF/DKIM/DMARC,并确认云厂商开放 25/587/993 端口。
|
||||
# Web 检查与构建
|
||||
cd apps/web
|
||||
pnpm run check
|
||||
|
||||
## 要求
|
||||
# 单容器源码构建验证
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
- Go 1.22+
|
||||
- Node.js 20+
|
||||
- Docker & Docker Compose(部署)
|
||||
## 生产注意事项
|
||||
|
||||
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`、SQLite 数据库、Maildir 与 DKIM 私钥。
|
||||
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
|
||||
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
|
||||
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
|
||||
@@ -73,21 +73,37 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
if err := a.attachUserAuthorization(r.Context(), &items[i].User); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user permissions")
|
||||
return
|
||||
}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("invalid email"))
|
||||
@@ -105,6 +121,10 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" && (actor == nil || actor.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
return
|
||||
@@ -116,12 +136,29 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id := newID("usr")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
permissionGroupIDs := req.PermissionGroupIDs
|
||||
if role == "admin" {
|
||||
permissionGroupIDs = nil
|
||||
}
|
||||
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, actor); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
user, err := a.adminUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
@@ -134,9 +171,10 @@ 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"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -155,21 +193,79 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
disabled := false
|
||||
existing, err := a.userByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
}
|
||||
disabled := existing.Disabled
|
||||
if req.Disabled != nil {
|
||||
disabled = *req.Disabled
|
||||
}
|
||||
if current != nil && current.ID == id && (disabled || role != "admin") {
|
||||
badRequest(w, errors.New("cannot remove your own admin access"))
|
||||
if a.isDefaultAdminUser(existing) && (role != "admin" || disabled) {
|
||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
_, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
shouldUpdatePermissionGroups := role == "admin" || existing.Role == "admin" || req.PermissionGroupIDs != nil
|
||||
var permissionGroupIDs []string
|
||||
if role == "user" {
|
||||
if req.PermissionGroupIDs != nil {
|
||||
permissionGroupIDs = *req.PermissionGroupIDs
|
||||
} else if existing.Role == "user" {
|
||||
for _, groupID := range existing.PermissionGroupIDs {
|
||||
if isAssignablePermissionGroupID(groupID) {
|
||||
permissionGroupIDs = append(permissionGroupIDs, groupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if current != nil && current.ID == id {
|
||||
next := *existing
|
||||
next.Role = role
|
||||
next.Disabled = disabled
|
||||
if shouldUpdatePermissionGroups {
|
||||
if role == "admin" {
|
||||
next.Permissions = allPermissionKeys()
|
||||
} else {
|
||||
permissions, err := a.effectivePermissionsForUserGroups(r.Context(), nil, permissionGroupIDs)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
next.Permissions = permissions
|
||||
}
|
||||
}
|
||||
if next.Disabled || !userHasAdminAccess(&next) {
|
||||
badRequest(w, errors.New("cannot remove your own admin access"))
|
||||
return
|
||||
}
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
if shouldUpdatePermissionGroups {
|
||||
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, current); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -183,6 +279,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if target.Role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can reset administrator passwords")
|
||||
return
|
||||
}
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
@@ -234,6 +340,16 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("cannot delete your own user"))
|
||||
return
|
||||
}
|
||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if a.isDefaultAdminUser(target) {
|
||||
badRequest(w, errors.New("default administrator cannot be deleted"))
|
||||
return
|
||||
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -409,6 +525,13 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
if err != nil {
|
||||
@@ -836,6 +959,9 @@ func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -71,9 +71,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
a.workerCancel = cancel
|
||||
go a.scheduledSendWorker(workerCtx)
|
||||
if strings.TrimSpace(cfg.MaildirRoot) != "" {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
|
||||
go a.maildirWorker(workerCtx)
|
||||
}
|
||||
go a.smtpEventsCleanupWorker(workerCtx)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -115,6 +116,23 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS permission_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
permissions_json TEXT NOT NULL DEFAULT '[]',
|
||||
limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
|
||||
system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_permission_groups (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id TEXT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, group_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_permission_groups_group ON user_permission_groups(group_id, user_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -238,6 +256,28 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
sent_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(status, send_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS smtp_send_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_smtp_send_events_user_created ON smtp_send_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS imap_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_imap_events_user_created ON imap_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS pop3_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -327,9 +367,45 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migratePermissionGroupLimits(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(permission_groups)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasLimits := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if name == "limits_json" {
|
||||
hasLimits = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasLimits {
|
||||
return nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
|
||||
// that implicitly created an admin mailbox with display_name "LanQin Admin".
|
||||
// Current seed() creates mailboxes with display_name = admin email, so this migration
|
||||
@@ -679,7 +755,7 @@ func (a *App) seed(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
return a.ensureConfiguredAdminSuperAdmin(ctx)
|
||||
}
|
||||
|
||||
adminPassword := a.cfg.AdminPassword
|
||||
@@ -736,6 +812,16 @@ func (a *App) seed(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (string, error) {
|
||||
name = normalizeDomain(name)
|
||||
if name == "" || !strings.Contains(name, ".") {
|
||||
|
||||
@@ -170,6 +170,33 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
|
||||
return mailbox
|
||||
}
|
||||
|
||||
func updateRegularPermissionGroup(t *testing.T, admin *testClient, permissions []string) PermissionGroup {
|
||||
t.Helper()
|
||||
var group PermissionGroup
|
||||
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
||||
"name": "Regular Users",
|
||||
"description": "Default permissions for regular users",
|
||||
"permissions": permissions,
|
||||
}, &group); code != http.StatusOK {
|
||||
t.Fatalf("update regular permission group code=%d group=%+v", code, group)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, permissions []string, limits PermissionLimits) PermissionGroup {
|
||||
t.Helper()
|
||||
var group PermissionGroup
|
||||
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
||||
"name": "Regular Users",
|
||||
"description": "Default permissions for regular users",
|
||||
"permissions": permissions,
|
||||
"limits": limits,
|
||||
}, &group); code != http.StatusOK {
|
||||
t.Fatalf("update regular permission group limits code=%d group=%+v", code, group)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func systemSettingsPayload(settings SystemSettings) map[string]any {
|
||||
return map[string]any{
|
||||
"publicHostname": settings.PublicHostname,
|
||||
@@ -367,6 +394,70 @@ func TestScheduleSendQueuesFutureMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionGroupMailLimits(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 code=%d body=%v", code, login)
|
||||
}
|
||||
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
|
||||
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil)
|
||||
recipient := createTestMailbox(t, admin, domainID, "limited-recipient", "Limited Recipient", "Password123!", nil)
|
||||
|
||||
user := &testClient{t: t, server: ts}
|
||||
if code := user.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("user login code=%d", code)
|
||||
}
|
||||
var me struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := user.do("GET", "/api/me", nil, &me); code != http.StatusOK {
|
||||
t.Fatalf("me code=%d user=%+v", code, me.User)
|
||||
}
|
||||
if me.User.Limits.MaxAttachmentMB != 1 || me.User.Limits.SMTPMinuteLimit != 1 || me.User.Limits.IMAPMinuteLimit != 1 || me.User.Limits.POP3MinuteLimit != 1 {
|
||||
t.Fatalf("user limits not attached: %+v", me.User.Limits)
|
||||
}
|
||||
|
||||
tooLargeAttachment := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("x"), 1024*1024+1))
|
||||
var errBody map[string]any
|
||||
if code := user.do("POST", "/api/mail/send", map[string]any{
|
||||
"mailboxId": sender.ID,
|
||||
"to": []string{recipient.Address},
|
||||
"subject": "too large",
|
||||
"text": "body",
|
||||
"html": "<p>body</p>",
|
||||
"attachments": []map[string]string{{
|
||||
"filename": "large.bin",
|
||||
"contentType": "application/octet-stream",
|
||||
"contentBase64": tooLargeAttachment,
|
||||
}},
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("oversized attachment should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
var sent MailMessage
|
||||
payload := map[string]any{
|
||||
"mailboxId": sender.ID,
|
||||
"to": []string{recipient.Address},
|
||||
"subject": "first limited send",
|
||||
"text": "body",
|
||||
"html": "<p>body</p>",
|
||||
}
|
||||
if code := user.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated {
|
||||
t.Fatalf("first send code=%d msg=%+v", code, sent)
|
||||
}
|
||||
payload["subject"] = "second limited send"
|
||||
if code := user.do("POST", "/api/mail/send", payload, &errBody); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("smtp minute limit should reject second send code=%d body=%v", code, errBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -886,6 +977,394 @@ func TestDNSRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(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 code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var groups struct {
|
||||
Items []PermissionGroup `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/permission-groups", nil, &groups); code != http.StatusOK || len(groups.Items) != len(defaultPermissionGroups()) {
|
||||
t.Fatalf("fixed permission groups code=%d groups=%+v", code, groups.Items)
|
||||
}
|
||||
groupByID := map[string]PermissionGroup{}
|
||||
for _, group := range groups.Items {
|
||||
groupByID[group.ID] = group
|
||||
}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
if _, ok := groupByID[group.ID]; !ok {
|
||||
t.Fatalf("missing fixed permission group %s in %+v", group.ID, groups.Items)
|
||||
}
|
||||
}
|
||||
if groupByID[PermissionGroupRegular].Limits != defaultPermissionLimits() {
|
||||
t.Fatalf("regular group limits=%+v want %+v", groupByID[PermissionGroupRegular].Limits, defaultPermissionLimits())
|
||||
}
|
||||
if groups.Items[0].ID != PermissionGroupSuperAdmin || groups.Items[1].ID != PermissionGroupRegular {
|
||||
t.Fatalf("unexpected fixed permission groups: %+v", groups.Items)
|
||||
}
|
||||
|
||||
var errBody map[string]any
|
||||
var users struct {
|
||||
Items []AdminUser `json:"items"`
|
||||
}
|
||||
var customGroup PermissionGroup
|
||||
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
|
||||
"name": "Mailbox Viewers",
|
||||
"description": "Can view mailboxes only",
|
||||
"permissions": []string{PermissionAdminOverview, PermissionMailboxesView},
|
||||
"limits": PermissionLimits{MaxAttachmentMB: 5, SMTPDailyLimit: 8, SMTPMinuteLimit: 2, IMAPMinuteLimit: 5, POP3MinuteLimit: 3},
|
||||
}, &customGroup); code != http.StatusCreated {
|
||||
t.Fatalf("custom permission group creation code=%d group=%+v", code, customGroup)
|
||||
}
|
||||
if customGroup.Limits.MaxAttachmentMB != 5 || customGroup.Limits.SMTPDailyLimit != 8 || customGroup.Limits.SMTPMinuteLimit != 2 || customGroup.Limits.IMAPMinuteLimit != 5 || customGroup.Limits.POP3MinuteLimit != 3 {
|
||||
t.Fatalf("custom permission group limits=%+v", customGroup.Limits)
|
||||
}
|
||||
if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) {
|
||||
t.Fatalf("custom permission group permissions=%+v", customGroup)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, map[string]any{
|
||||
"name": "Changed",
|
||||
"description": "Should not change",
|
||||
"permissions": []string{PermissionMailboxesView},
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
|
||||
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
||||
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupRegular, nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("regular user group delete should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "invalid-group@lanqin.local",
|
||||
"displayName": "Invalid Group",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{PermissionGroupSuperAdmin},
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("assigning super admin group should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
var mailboxAdminGroup PermissionGroup
|
||||
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
|
||||
"name": "Mailbox Admins",
|
||||
"description": "Can manage mailboxes",
|
||||
"permissions": []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionUsersView,
|
||||
PermissionDomainsView,
|
||||
PermissionMailboxesView,
|
||||
PermissionMailboxesCreate,
|
||||
PermissionMailboxesUpdate,
|
||||
PermissionMailboxesDelete,
|
||||
},
|
||||
}, &mailboxAdminGroup); code != http.StatusCreated {
|
||||
t.Fatalf("create mailbox admin group code=%d group=%+v", code, mailboxAdminGroup)
|
||||
}
|
||||
|
||||
var userAdminGroup PermissionGroup
|
||||
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
|
||||
"name": "User Admins",
|
||||
"description": "Can manage users",
|
||||
"permissions": []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionUsersView,
|
||||
PermissionUsersCreate,
|
||||
PermissionUsersUpdate,
|
||||
PermissionUsersDelete,
|
||||
PermissionUsersResetPassword,
|
||||
PermissionGroupsView,
|
||||
},
|
||||
}, &userAdminGroup); code != http.StatusCreated {
|
||||
t.Fatalf("create user admin group code=%d group=%+v", code, userAdminGroup)
|
||||
}
|
||||
|
||||
var mailboxUser AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "mailbox-admin@lanqin.local",
|
||||
"displayName": "Mailbox Admin",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{mailboxAdminGroup.ID},
|
||||
}, &mailboxUser); code != http.StatusCreated {
|
||||
t.Fatalf("create mailbox admin user code=%d user=%+v", code, mailboxUser)
|
||||
}
|
||||
if mailboxUser.Role != "user" || !containsString(mailboxUser.PermissionGroupIDs, PermissionGroupRegular) || !containsString(mailboxUser.PermissionGroupIDs, mailboxAdminGroup.ID) || !userHasPermission(&mailboxUser.User, PermissionMailboxesManage) || userHasPermission(&mailboxUser.User, PermissionSystemSettings) {
|
||||
t.Fatalf("mailbox admin authorization=%+v", mailboxUser.User)
|
||||
}
|
||||
|
||||
var plainUser AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "plain-user@lanqin.local",
|
||||
"displayName": "Plain User",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{},
|
||||
}, &plainUser); code != http.StatusCreated {
|
||||
t.Fatalf("create plain user code=%d user=%+v", code, plainUser)
|
||||
}
|
||||
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionAdminOverview) {
|
||||
t.Fatalf("plain user should inherit regular permissions: %+v", plainUser.User)
|
||||
}
|
||||
|
||||
var customUser AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "mailbox-viewer@lanqin.local",
|
||||
"displayName": "Mailbox Viewer",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{customGroup.ID},
|
||||
}, &customUser); code != http.StatusCreated {
|
||||
t.Fatalf("create custom group user code=%d user=%+v", code, customUser)
|
||||
}
|
||||
if !userHasPermission(&customUser.User, PermissionMailboxesView) || userHasPermission(&customUser.User, PermissionMailboxesCreate) {
|
||||
t.Fatalf("custom group user authorization=%+v", customUser.User)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+customGroup.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("assigned custom permission group delete should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
mailboxAdmin := &testClient{t: t, server: ts}
|
||||
if code := mailboxAdmin.do("POST", "/api/auth/login", map[string]string{"email": "mailbox-admin@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("mailbox admin login code=%d", code)
|
||||
}
|
||||
var mailboxList struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := mailboxAdmin.do("GET", "/api/admin/mailboxes", nil, &mailboxList); code != http.StatusOK {
|
||||
t.Fatalf("mailbox admin should access mailboxes code=%d", code)
|
||||
}
|
||||
if code := mailboxAdmin.do("GET", "/api/admin/settings", nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("mailbox admin settings should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := mailboxAdmin.do("GET", "/api/admin/users", nil, &errBody); code != http.StatusOK {
|
||||
t.Fatalf("mailbox admin should read users for mailbox ownership code=%d body=%v", code, errBody)
|
||||
}
|
||||
viewer := &testClient{t: t, server: ts}
|
||||
if code := viewer.do("POST", "/api/auth/login", map[string]string{"email": "mailbox-viewer@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("mailbox viewer login code=%d", code)
|
||||
}
|
||||
if code := viewer.do("GET", "/api/admin/mailboxes", nil, &mailboxList); code != http.StatusOK {
|
||||
t.Fatalf("mailbox viewer should read mailboxes code=%d", code)
|
||||
}
|
||||
if code := viewer.do("POST", "/api/admin/mailboxes", map[string]any{
|
||||
"domainId": mustDefaultDomainID(t, a),
|
||||
"localPart": "blocked-create",
|
||||
"displayName": "Blocked Create",
|
||||
"password": "Password123!",
|
||||
"quotaMb": 1024,
|
||||
"role": "user",
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("mailbox viewer should not create mailboxes code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := mailboxAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "blocked-by-mailbox-admin@lanqin.local",
|
||||
"displayName": "Blocked",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{mailboxAdminGroup.ID},
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("mailbox admin should not create users code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
var userManager AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "user-admin@lanqin.local",
|
||||
"displayName": "User Admin",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{userAdminGroup.ID},
|
||||
}, &userManager); code != http.StatusCreated {
|
||||
t.Fatalf("create user admin code=%d user=%+v", code, userManager)
|
||||
}
|
||||
userAdmin := &testClient{t: t, server: ts}
|
||||
if code := userAdmin.do("POST", "/api/auth/login", map[string]string{"email": "user-admin@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("user admin login code=%d", code)
|
||||
}
|
||||
if code := userAdmin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK {
|
||||
t.Fatalf("user admin users code=%d body=%v", code, users)
|
||||
}
|
||||
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "delegated-mailbox@lanqin.local",
|
||||
"displayName": "Delegated Mailbox",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{mailboxAdminGroup.ID},
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("user admin should not assign mailbox admin group code=%d body=%v", code, errBody)
|
||||
}
|
||||
var regularUser AdminUser
|
||||
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "delegated-user@lanqin.local",
|
||||
"displayName": "Delegated User",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{userAdminGroup.ID},
|
||||
}, ®ularUser); code != http.StatusCreated {
|
||||
t.Fatalf("user admin should assign own group code=%d user=%+v", code, regularUser)
|
||||
}
|
||||
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "delegated-super@lanqin.local",
|
||||
"displayName": "Delegated Super",
|
||||
"role": "admin",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{},
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("user admin should not create super admin code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
if code := admin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK || len(users.Items) == 0 {
|
||||
t.Fatalf("admin users code=%d items=%d", code, len(users.Items))
|
||||
}
|
||||
var defaultAdmin AdminUser
|
||||
for _, user := range users.Items {
|
||||
if user.Email == "admin@lanqin.local" {
|
||||
defaultAdmin = user
|
||||
break
|
||||
}
|
||||
}
|
||||
if defaultAdmin.ID == "" || !defaultAdmin.Protected || defaultAdmin.Role != "admin" {
|
||||
t.Fatalf("default admin should be protected super admin: %+v", defaultAdmin.User)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+defaultAdmin.ID, map[string]any{
|
||||
"displayName": "LanQin Admin",
|
||||
"role": "user",
|
||||
"disabled": false,
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("default admin downgrade should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+defaultAdmin.ID, map[string]any{
|
||||
"displayName": "LanQin Admin",
|
||||
"role": "admin",
|
||||
"disabled": true,
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("default admin disable should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/users/"+defaultAdmin.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("default admin delete should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySystemPermissionGroupsAreCleanedUp(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
legacyIDs := []string{
|
||||
"pg_permission_manager",
|
||||
"pg_user_manager",
|
||||
"pg_system_operator",
|
||||
"pg_mail_operator",
|
||||
}
|
||||
|
||||
for _, groupID := range legacyIDs {
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,1,?,?)`, groupID, "Legacy "+groupID, "", "[]", now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, groupID := range legacyIDs {
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM permission_groups WHERE id=?`, groupID).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy permission group %s was not removed", groupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegularUserMailPermissionsAreEnforced(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 code=%d body=%v", code, login)
|
||||
}
|
||||
mb := createTestMailbox(t, admin, mustDefaultDomainID(t, a), "front-perm", "Front Permissions", "Password123!", nil)
|
||||
|
||||
user := &testClient{t: t, server: ts}
|
||||
if code := user.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("user login code=%d", code)
|
||||
}
|
||||
var mine struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := user.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].ID != mb.ID {
|
||||
t.Fatalf("regular user should access mail front code=%d items=%+v", code, mine.Items)
|
||||
}
|
||||
var errBody map[string]any
|
||||
if code := user.do("GET", "/api/admin/overview", nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("regular mail permissions should not grant admin access code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess))
|
||||
noAccess := &testClient{t: t, server: ts}
|
||||
if code := noAccess.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("no access login code=%d", code)
|
||||
}
|
||||
if code := noAccess.do("GET", "/api/mail/mailboxes", nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("missing mail access should block mailbox list code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend))
|
||||
noSend := &testClient{t: t, server: ts}
|
||||
if code := noSend.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("no send login code=%d", code)
|
||||
}
|
||||
sendPayload := map[string]any{
|
||||
"mailboxId": mb.ID,
|
||||
"to": []string{"someone@example.test"},
|
||||
"subject": "blocked send",
|
||||
"text": "body",
|
||||
"html": "<p>body</p>",
|
||||
}
|
||||
if code := noSend.do("POST", "/api/mail/send", sendPayload, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("missing send permission should block send code=%d body=%v", code, errBody)
|
||||
}
|
||||
schedulePayload := map[string]any{
|
||||
"mailboxId": mb.ID,
|
||||
"to": []string{"someone@example.test"},
|
||||
"subject": "blocked schedule",
|
||||
"text": "body",
|
||||
"html": "<p>body</p>",
|
||||
"sendAt": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if code := noSend.do("POST", "/api/mail/schedule-send", schedulePayload, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("missing send permission should block scheduled send creation code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := noSend.do("GET", "/api/mail/scheduled-sends?mailboxId="+mb.ID, nil, &struct {
|
||||
Items []ScheduledSend `json:"items"`
|
||||
}{}); code != http.StatusOK {
|
||||
t.Fatalf("schedule management permission should remain usable code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaildirSyncImportsRFC822(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
@@ -1049,3 +1528,26 @@ func mustDefaultDomainID(t *testing.T, a *App) string {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func containsString(items []string, needle string) bool {
|
||||
for _, item := range items {
|
||||
if item == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func withoutPermissions(items []string, removed ...string) []string {
|
||||
removedSet := map[string]bool{}
|
||||
for _, item := range removed {
|
||||
removedSet[item] = true
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !removedSet[item] {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// mailMessagesPageSize is the max number of messages returned per page in mail listing.
|
||||
const mailMessagesPageSize = 30
|
||||
|
||||
type AttachmentInput struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"contentType"`
|
||||
@@ -143,7 +146,7 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := 30
|
||||
limit := mailMessagesPageSize
|
||||
|
||||
if q != "" {
|
||||
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
|
||||
@@ -216,6 +219,53 @@ func (a *App) handleCreateMailLabel(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusCreated, label)
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
labelID := chi.URLParam(r, "id")
|
||||
if labelID == "" {
|
||||
badRequest(w, fmt.Errorf("label id is required"))
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if !a.labelBelongsToMailbox(ctx, labelID, mb.ID) {
|
||||
respondError(w, http.StatusNotFound, "label not found")
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to begin transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ?", labelID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to remove label associations")
|
||||
return
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mb.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete label")
|
||||
return
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n == 0 {
|
||||
respondError(w, http.StatusNotFound, "label not found")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to commit transaction")
|
||||
return
|
||||
}
|
||||
labels, err := a.labelsForMailbox(ctx, mb.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load labels")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"labels": labels})
|
||||
}
|
||||
|
||||
func (a *App) handleAddMessageLabel(w http.ResponseWriter, r *http.Request) {
|
||||
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
|
||||
if err != nil {
|
||||
@@ -277,7 +327,7 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("markRead") != "0" && !msg.IsRead {
|
||||
if r.URL.Query().Get("markRead") != "0" && !msg.IsRead && userHasPermission(currentUser(r), PermissionMailOrganize) {
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
msg.IsRead = true
|
||||
}
|
||||
@@ -344,7 +394,7 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
msg, err := a.sendMailNow(r.Context(), mb, req)
|
||||
msg, err := a.sendMailNow(r.Context(), currentUser(r), mb, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNoRecipients) {
|
||||
badRequest(w, err)
|
||||
@@ -354,6 +404,14 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errAttachmentTooLarge) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errSMTPRateLimited) {
|
||||
respondError(w, http.StatusTooManyRequests, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "smtp delivery failed:") {
|
||||
respondError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
@@ -366,8 +424,13 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var errNoRecipients = errors.New("at least one recipient is required")
|
||||
var errInvalidMIME = errors.New("invalid mime message")
|
||||
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
|
||||
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
|
||||
|
||||
func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.To, req.CC, req.BCC = dedupeEmails(req.To), dedupeEmails(req.CC), dedupeEmails(req.BCC)
|
||||
allRecipients := append(append([]string{}, req.To...), append(req.CC, req.BCC...)...)
|
||||
if len(allRecipients) == 0 {
|
||||
@@ -392,6 +455,9 @@ func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errInvalidMIME, err)
|
||||
}
|
||||
if err := a.recordSMTPRate(ctx, user, mb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.SMTPHost != "" {
|
||||
if err := a.sendSMTP(mb.Address, allRecipients, mimeBytes); err != nil {
|
||||
return nil, fmt.Errorf("smtp delivery failed: %w", err)
|
||||
@@ -458,6 +524,164 @@ func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func userLimits(user *User) PermissionLimits {
|
||||
if user == nil {
|
||||
return defaultPermissionLimits()
|
||||
}
|
||||
return user.Limits
|
||||
}
|
||||
|
||||
func validateAttachmentLimit(attachments []AttachmentInput, limits PermissionLimits) error {
|
||||
limitBytes := attachmentLimitBytes(limits)
|
||||
if limitBytes == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, att := range attachments {
|
||||
decodedLen, err := decodedBase64Len(att.ContentBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errInvalidMIME, err)
|
||||
}
|
||||
if decodedLen > limitBytes {
|
||||
return fmt.Errorf("%w: max %d MB", errAttachmentTooLarge, limits.MaxAttachmentMB)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodedBase64Len(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(data)), nil
|
||||
}
|
||||
|
||||
var errIMAPRateLimited = errors.New("imap rate limit exceeded")
|
||||
var errPOP3RateLimited = errors.New("pop3 rate limit exceeded")
|
||||
|
||||
func (a *App) checkAndRecordProtocolRate(ctx context.Context, user *User, mb *Mailbox, table string, dailyLimit, minuteLimit int) error {
|
||||
if dailyLimit == 0 && minuteLimit == 0 {
|
||||
return nil
|
||||
}
|
||||
now := a.now().UTC()
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if dailyLimit > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE user_id=? AND created_at>=?", user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= dailyLimit {
|
||||
return fmt.Errorf("daily limit %d", dailyLimit)
|
||||
}
|
||||
}
|
||||
if minuteLimit > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE user_id=? AND created_at>=?", user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= minuteLimit {
|
||||
return fmt.Errorf("per-minute limit %d", minuteLimit)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO "+table+"(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)", newID("evt"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) handleAuthPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respondJSON(w, http.StatusCreated, map[string]string{"status": "allow"})
|
||||
return
|
||||
}
|
||||
var user *User
|
||||
if req.Username != "" {
|
||||
var passHash string
|
||||
user, passHash, _ = a.userByEmail(r.Context(), req.Username)
|
||||
_ = passHash
|
||||
}
|
||||
if user == nil || user.Disabled {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respondJSON(w, http.StatusCreated, map[string]string{"status": "deny", "reason": "user not found or disabled"})
|
||||
return
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respondJSON(w, http.StatusCreated, map[string]string{"status": "allow"})
|
||||
return
|
||||
}
|
||||
limits := user.Limits
|
||||
var err error
|
||||
switch req.Protocol {
|
||||
case "imap", "IMAP":
|
||||
if limits.IMAPMinuteLimit > 0 {
|
||||
err = a.checkAndRecordProtocolRate(r.Context(), user, nil, "imap_events", 0, limits.IMAPMinuteLimit)
|
||||
}
|
||||
case "pop3", "POP3":
|
||||
if limits.POP3MinuteLimit > 0 {
|
||||
err = a.checkAndRecordProtocolRate(r.Context(), user, nil, "pop3_events", 0, limits.POP3MinuteLimit)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"status": "deny", "reason": err.Error()})
|
||||
} else {
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"status": "allow"})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordSMTPRate(ctx context.Context, user *User, mb *Mailbox) error {
|
||||
if user == nil || mb == nil || user.Role == "admin" {
|
||||
return nil
|
||||
}
|
||||
limits := user.Limits
|
||||
if limits.SMTPDailyLimit == 0 && limits.SMTPMinuteLimit == 0 {
|
||||
return nil
|
||||
}
|
||||
now := a.now().UTC()
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if limits.SMTPDailyLimit > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= limits.SMTPDailyLimit {
|
||||
return fmt.Errorf("%w: daily limit %d", errSMTPRateLimited, limits.SMTPDailyLimit)
|
||||
}
|
||||
}
|
||||
if limits.SMTPMinuteLimit > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= limits.SMTPMinuteLimit {
|
||||
return fmt.Errorf("%w: per-minute limit %d", errSMTPRateLimited, limits.SMTPMinuteLimit)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO smtp_send_events(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)`, newID("smtp"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
var req mailDraftInput
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
@@ -469,6 +693,16 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
if req.Attachments != nil {
|
||||
if err := validateAttachmentLimit(*req.Attachments, userLimits(currentUser(r))); err != nil {
|
||||
if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML}
|
||||
compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC)
|
||||
subject := strings.TrimSpace(compose.Subject)
|
||||
@@ -640,6 +874,14 @@ func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, Attachments: req.Attachments}
|
||||
if err := validateAttachmentLimit(compose.Attachments, userLimits(currentUser(r))); err != nil {
|
||||
if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC)
|
||||
if len(append(append([]string{}, compose.To...), append(compose.CC, compose.BCC...)...)) == 0 {
|
||||
badRequest(w, errNoRecipients)
|
||||
@@ -723,6 +965,33 @@ func (a *App) scheduledSendWorker(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) smtpEventsCleanupWorker(ctx context.Context) {
|
||||
a.log.Info("smtp events cleanup worker started")
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("smtp events cleanup worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.cleanupStaleEvents(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cleanupStaleEvents(ctx context.Context) {
|
||||
cutoff := a.now().UTC().Add(-24 * time.Hour).Format(time.RFC3339Nano)
|
||||
for _, table := range []string{"smtp_send_events", "imap_events", "pop3_events"} {
|
||||
result, err := a.db.ExecContext(ctx, "DELETE FROM "+table+" WHERE created_at<?", cutoff)
|
||||
if err != nil {
|
||||
a.log.Warn("event cleanup failed", "table", table, "error", err)
|
||||
} else if n, _ := result.RowsAffected(); n > 0 {
|
||||
a.log.Debug("event cleanup deleted rows", "table", table, "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processDueScheduledSends(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,mailbox_id,draft_id,payload_json FROM scheduled_sends WHERE status='pending' AND send_at<=? ORDER BY send_at LIMIT 20`, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
@@ -770,8 +1039,13 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
|
||||
a.markScheduledSendFailed(ctx, id, "mailbox not found")
|
||||
return
|
||||
}
|
||||
user, err := a.userByID(ctx, mb.UserID)
|
||||
if err != nil || !userHasPermission(user, PermissionMailSchedule) || !userHasPermission(user, PermissionMailSend) {
|
||||
a.markScheduledSendFailed(ctx, id, "mail send permission revoked")
|
||||
return
|
||||
}
|
||||
compose := mailComposeInput{MailboxID: payload.MailboxID, To: payload.To, CC: payload.CC, BCC: payload.BCC, Subject: payload.Subject, Text: payload.Text, HTML: payload.HTML, Attachments: payload.Attachments}
|
||||
if _, err := a.sendMailNow(ctx, mb, compose); err != nil {
|
||||
if _, err := a.sendMailNow(ctx, user, mb, compose); err != nil {
|
||||
a.markScheduledSendFailed(ctx, id, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": permissionCatalog()})
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultPermissionLimits(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, defaultPermissionLimits())
|
||||
}
|
||||
|
||||
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at
|
||||
FROM permission_groups
|
||||
ORDER BY created_at ASC,name ASC`)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
|
||||
items := []PermissionGroup{}
|
||||
for rows.Next() {
|
||||
var item PermissionGroup
|
||||
var rawPermissions, rawLimits, created, updated string
|
||||
var system int
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &rawPermissions, &rawLimits, &system, &created, &updated); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
|
||||
return
|
||||
}
|
||||
item.Permissions = decodeStoredPermissions(rawPermissions)
|
||||
item.Limits = decodeStoredLimits(rawLimits)
|
||||
item.System = intBool(system)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
var adminCount, regularCount int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u
|
||||
WHERE u.role<>'admin'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_permission_groups upg
|
||||
WHERE upg.user_id=u.id AND upg.group_id NOT IN (?,?)
|
||||
)`, PermissionGroupSuperAdmin, PermissionGroupRegular,
|
||||
).Scan(®ularCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
switch items[i].ID {
|
||||
case PermissionGroupSuperAdmin:
|
||||
items[i].UserCount = adminCount
|
||||
case PermissionGroupRegular:
|
||||
items[i].UserCount = regularCount
|
||||
default:
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, items[i].ID).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
items[i].UserCount = count
|
||||
}
|
||||
}
|
||||
sortPermissionGroups(items)
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "catalog": permissionCatalog()})
|
||||
}
|
||||
|
||||
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits *PermissionLimits `json:"limits"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
permissions, err := normalizePermissionList(req.Permissions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !actorCanGrantPermissions(currentUser(r), permissions) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
limits := defaultPermissionLimits()
|
||||
if req.Limits != nil {
|
||||
var err error
|
||||
limits, err = normalizePermissionLimits(*req.Limits)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !actorCanGrantLimits(currentUser(r), limits) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
|
||||
return
|
||||
}
|
||||
id := newID("pg")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
group, err := a.permissionGroupByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load permission group")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, group)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var existingSystem int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if id == PermissionGroupSuperAdmin {
|
||||
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits *PermissionLimits `json:"limits"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
permissions, err := normalizePermissionList(req.Permissions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !actorCanGrantPermissions(currentUser(r), permissions) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
limits := defaultPermissionLimits()
|
||||
if req.Limits != nil {
|
||||
var err error
|
||||
limits, err = normalizePermissionLimits(*req.Limits)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var rawLimits string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT limits_json FROM permission_groups WHERE id=?`, id).Scan(&rawLimits); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
limits = decodeStoredLimits(rawLimits)
|
||||
}
|
||||
if !actorCanGrantLimits(currentUser(r), limits) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,limits_json=?,updated_at=? WHERE id=?`,
|
||||
name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
group, err := a.permissionGroupByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load permission group")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, group)
|
||||
}
|
||||
|
||||
func (a *App) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var system int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&system); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if intBool(system) {
|
||||
respondError(w, http.StatusForbidden, "system permission groups cannot be deleted")
|
||||
return
|
||||
}
|
||||
var userCount int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, id).Scan(&userCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check permission group")
|
||||
return
|
||||
}
|
||||
if userCount > 0 {
|
||||
badRequest(w, errors.New("permission group is assigned to users"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM permission_groups WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete permission group")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ func (a *App) Router() http.Handler {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(a.corsMiddleware)
|
||||
|
||||
r.Post("/auth-policy", a.handleAuthPolicy)
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "time": a.now().UTC()})
|
||||
})
|
||||
@@ -36,87 +37,94 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requireAuth).Get("/me", a.handleMe)
|
||||
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
|
||||
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
|
||||
r.With(a.requireAuth).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
|
||||
r.With(a.requireAuth).Post("/me/mailboxes/apply", a.handleApplyMailbox)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox)
|
||||
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
|
||||
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
|
||||
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
|
||||
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
|
||||
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
|
||||
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
||||
r.With(a.requireAuth).Get("/me/signatures", a.handleListSignatures)
|
||||
r.With(a.requireAuth).Post("/me/signatures", a.handleCreateSignature)
|
||||
r.With(a.requireAuth).Post("/me/signatures/{id}", a.handleUpdateSignature)
|
||||
r.With(a.requireAuth).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
|
||||
r.With(a.requireAuth).Delete("/me/signatures/{id}", a.handleDeleteSignature)
|
||||
r.With(a.requireAuth).Get("/me/signatures/default", a.handleDefaultSignature)
|
||||
r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
|
||||
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
|
||||
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
|
||||
r.With(a.requireAuth).Get("/me/blocked-senders", a.handleListBlockedSenders)
|
||||
r.With(a.requireAuth).Post("/me/blocked-senders", a.handleCreateBlockedSender)
|
||||
r.With(a.requireAuth).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
||||
r.With(a.requireAuth).Get("/me/stats", a.handleMailStats)
|
||||
r.With(a.requireAuth).Post("/me/cleanup", a.handleMailCleanup)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Get("/me/contacts", a.handleListContacts)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Post("/me/contacts", a.handleCreateContact)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures", a.handleListSignatures)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures", a.handleCreateSignature)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}", a.handleUpdateSignature)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Delete("/me/signatures/{id}", a.handleDeleteSignature)
|
||||
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)).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)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
|
||||
r.With(a.requireAuth).Get("/events", a.handleEvents)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Get("/mail/mailboxes", a.handleMyMailboxes)
|
||||
r.Get("/mail/folders", a.handleMailFolders)
|
||||
r.Get("/mail/labels", a.handleMailLabels)
|
||||
r.Post("/mail/labels", a.handleCreateMailLabel)
|
||||
r.Get("/mail/messages", a.handleMailMessages)
|
||||
r.Get("/mail/starred", a.handleStarredMessages)
|
||||
r.Get("/mail/messages/{id}", a.handleMailMessage)
|
||||
r.Post("/mail/send", a.handleMailSend)
|
||||
r.Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||
r.Post("/mail/schedule-send", a.handleScheduleSend)
|
||||
r.Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||
r.Post("/mail/drafts", a.handleSaveDraft)
|
||||
r.Post("/mail/drafts/{id}", a.handleSaveDraft)
|
||||
r.Delete("/mail/drafts/{id}", a.handleDeleteDraft)
|
||||
r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
|
||||
r.Post("/mail/messages/{id}/star", a.handleStar)
|
||||
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||
r.Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
||||
r.Post("/mail/messages/{id}/move", a.handleMove)
|
||||
r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||
r.Get("/mail/attachments/{id}", a.handleAttachment)
|
||||
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
|
||||
r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
|
||||
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/messages/{id}", a.handleMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts", a.handleSaveDraft)
|
||||
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts/{id}", a.handleSaveDraft)
|
||||
r.With(a.requirePermission(PermissionMailDrafts)).Delete("/mail/drafts/{id}", a.handleDeleteDraft)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Use(a.requireAdmin)
|
||||
r.Get("/admin/overview", a.handleAdminOverview)
|
||||
r.Get("/admin/users", a.handleListUsers)
|
||||
r.Post("/admin/users", a.handleCreateUser)
|
||||
r.Post("/admin/users/{id}", a.handleUpdateUser)
|
||||
r.Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||
r.Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||
r.Get("/admin/domains", a.handleListDomains)
|
||||
r.Post("/admin/domains", a.handleCreateDomain)
|
||||
r.Post("/admin/domains/{id}", a.handleUpdateDomain)
|
||||
r.Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
||||
r.Get("/admin/mailboxes", a.handleListMailboxes)
|
||||
r.Post("/admin/mailboxes", a.handleCreateMailbox)
|
||||
r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
||||
r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
||||
r.Get("/admin/aliases", a.handleListAliases)
|
||||
r.Post("/admin/aliases", a.handleCreateAlias)
|
||||
r.Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.Get("/admin/messages", a.handleAdminMessages)
|
||||
r.Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
||||
r.Use(a.requireAdminAccess)
|
||||
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)
|
||||
r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser)
|
||||
r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||
r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-limits/defaults", a.handleDefaultPermissionLimits)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups)
|
||||
r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup)
|
||||
r.With(a.requirePermission(PermissionGroupsUpdate)).Post("/admin/permission-groups/{id}", a.handleUpdatePermissionGroup)
|
||||
r.With(a.requirePermission(PermissionGroupsDelete)).Delete("/admin/permission-groups/{id}", a.handleDeletePermissionGroup)
|
||||
r.With(a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/admin/domains", a.handleListDomains)
|
||||
r.With(a.requirePermission(PermissionDomainsCreate)).Post("/admin/domains", a.handleCreateDomain)
|
||||
r.With(a.requirePermission(PermissionDomainsUpdate)).Post("/admin/domains/{id}", a.handleUpdateDomain)
|
||||
r.With(a.requirePermission(PermissionDomainsDelete)).Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
||||
r.With(a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/admin/mailboxes", a.handleListMailboxes)
|
||||
r.With(a.requirePermission(PermissionMailboxesCreate)).Post("/admin/mailboxes", a.handleCreateMailbox)
|
||||
r.With(a.requirePermission(PermissionMailboxesUpdate)).Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
||||
r.With(a.requirePermission(PermissionMailboxesDelete)).Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
||||
r.With(a.requirePermission(PermissionAliasesView)).Get("/admin/aliases", a.handleListAliases)
|
||||
r.With(a.requirePermission(PermissionAliasesCreate)).Post("/admin/aliases", a.handleCreateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
|
||||
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
r.With(a.requirePermission(PermissionDNSView)).Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.With(a.requirePermission(PermissionDNSCheck)).Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,17 +160,6 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "admin role required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) *User {
|
||||
user, _ := r.Context().Value(userContextKey).(*User)
|
||||
return user
|
||||
@@ -188,6 +185,9 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
if u.Disabled {
|
||||
return nil, errors.New("disabled")
|
||||
}
|
||||
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -206,6 +206,9 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &u, passwordHash, nil
|
||||
}
|
||||
|
||||
@@ -223,5 +226,8 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &u, secret, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,18 @@ package app
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Protected bool `json:"protected"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from "react"
|
||||
import { Navigate } from "react-router-dom"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { hasAdminAccess } from "@/lib/permissions"
|
||||
|
||||
export function AdminOnly({ children }: { children: React.ReactNode }) {
|
||||
const me = useMe()
|
||||
if (me.isLoading) return null
|
||||
if (!me.data?.user) return <Navigate to="/login" replace />
|
||||
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
|
||||
if (!hasAdminAccess(me.data.user)) return <Navigate to="/" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as React from "react"
|
||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, Users } from "lucide-react"
|
||||
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, Users } from "lucide-react"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useLogout } from "@/hooks/use-logout"
|
||||
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 { hasAnyPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -24,14 +26,15 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
const adminSections = [
|
||||
{ key: "overview", label: "概览", icon: <BarChart3 /> },
|
||||
{ key: "users", label: "用户", icon: <Users /> },
|
||||
{ key: "domains", label: "域名", icon: <Globe2 /> },
|
||||
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox /> },
|
||||
{ key: "aliases", label: "别名转发", icon: <Copy /> },
|
||||
{ key: "messages", label: "全部邮件", icon: <Inbox /> },
|
||||
{ key: "settings", label: "系统设置", icon: <Settings /> },
|
||||
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||
{ key: "overview", label: "概览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "users", label: "用户", icon: <Users />, permissions: ["admin.users.view"] },
|
||||
{ key: "permissionGroups", label: "权限组", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "domains", label: "域名", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
||||
{ key: "aliases", label: "别名转发", icon: <Copy />, permissions: ["admin.aliases.view"] },
|
||||
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||
]
|
||||
|
||||
export function ProtectedLayout() {
|
||||
@@ -52,6 +55,7 @@ function ProtectedContent() {
|
||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
|
||||
|
||||
if (isMailRoute || isProfileRoute) {
|
||||
return <Outlet />
|
||||
@@ -77,11 +81,11 @@ function ProtectedContent() {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{user.role === "admin" && isAdminRoute && (
|
||||
{isAdminRoute && visibleAdminSections.length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<AdminSectionItems activeSection={adminSection} />
|
||||
<AdminSectionItems activeSection={adminSection} sections={visibleAdminSections} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -102,7 +106,7 @@ function ProtectedContent() {
|
||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
|
||||
{user.role === "admin" ? "管理员" : "用户"}
|
||||
{user.role === "admin" ? "超级管理员" : "普通用户"}
|
||||
</Badge>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
@@ -121,7 +125,7 @@ function ProtectedContent() {
|
||||
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
|
||||
<SidebarTrigger aria-label="打开导航" />
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{isAdminRoute ? adminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
|
||||
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
@@ -131,13 +135,13 @@ function ProtectedContent() {
|
||||
)
|
||||
}
|
||||
|
||||
function AdminSectionItems({ activeSection }: { activeSection: string }) {
|
||||
function AdminSectionItems({ activeSection, sections }: { activeSection: string; sections: typeof adminSections }) {
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
function closeMobile() {
|
||||
if (isMobile) setOpenMobile(false)
|
||||
}
|
||||
return adminSections.map((item) => (
|
||||
return sections.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton asChild isActive={activeSection === item.key} tooltip={item.label}>
|
||||
<Link to={`/admin?section=${item.key}`} onClick={closeMobile}>
|
||||
|
||||
@@ -162,3 +162,29 @@ vue-devtools-anchor,
|
||||
[class*="vue-devtools"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Label badge: hide delete button by default, show on hover (desktop) */
|
||||
.label-badge-delete {
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transition: visibility 0.1s, width 0.1s, padding 0.1s, opacity 0.15s;
|
||||
}
|
||||
.label-badge:hover .label-badge-delete,
|
||||
.label-badge:focus-within .label-badge-delete {
|
||||
visibility: visible;
|
||||
width: 1rem;
|
||||
padding: 0;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Touch devices: always show the delete button inside label badges */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.label-badge-delete {
|
||||
visibility: visible;
|
||||
width: 1rem;
|
||||
padding: 0;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,56 @@
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
|
||||
export type PermissionKey =
|
||||
| "mail.access"
|
||||
| "mail.messages.read"
|
||||
| "mail.messages.send"
|
||||
| "mail.messages.drafts"
|
||||
| "mail.messages.schedule"
|
||||
| "mail.messages.organize"
|
||||
| "mail.labels.manage"
|
||||
| "mail.attachments.download"
|
||||
| "mail.contacts.manage"
|
||||
| "mail.signatures.manage"
|
||||
| "mail.rules.manage"
|
||||
| "mail.blocked_senders.manage"
|
||||
| "mail.stats.view"
|
||||
| "mail.mailboxes.apply"
|
||||
| "admin.overview.view"
|
||||
| "admin.users.view"
|
||||
| "admin.users.create"
|
||||
| "admin.users.update"
|
||||
| "admin.users.delete"
|
||||
| "admin.users.reset_password"
|
||||
| "admin.permission_groups.view"
|
||||
| "admin.permission_groups.create"
|
||||
| "admin.permission_groups.update"
|
||||
| "admin.permission_groups.delete"
|
||||
| "admin.domains.view"
|
||||
| "admin.domains.create"
|
||||
| "admin.domains.update"
|
||||
| "admin.domains.delete"
|
||||
| "admin.dns.view"
|
||||
| "admin.dns.check"
|
||||
| "admin.mailboxes.view"
|
||||
| "admin.mailboxes.create"
|
||||
| "admin.mailboxes.update"
|
||||
| "admin.mailboxes.delete"
|
||||
| "admin.aliases.view"
|
||||
| "admin.aliases.create"
|
||||
| "admin.aliases.update"
|
||||
| "admin.aliases.delete"
|
||||
| "admin.messages.view"
|
||||
| "admin.messages.read"
|
||||
| "admin.messages.attachments"
|
||||
| "admin.settings.view"
|
||||
| "admin.settings.update"
|
||||
| "admin.settings.test_smtp"
|
||||
| "admin.templates.view"
|
||||
| "admin.templates.update"
|
||||
| "admin.templates.reset"
|
||||
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
|
||||
export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
|
||||
export type PermissionGroupSummary = { id: string; name: string }
|
||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
||||
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -63,8 +63,13 @@ export const api = {
|
||||
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
|
||||
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
|
||||
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
|
||||
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
|
||||
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
|
||||
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
||||
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
|
||||
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
|
||||
@@ -104,6 +109,7 @@ export const api = {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
return request<MailLabel>(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) })
|
||||
},
|
||||
deleteLabel: (id: string, mailboxId?: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
|
||||
messages: (folder: string, q = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ folder, q, cursor })
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { PermissionKey, User } from "@/lib/api-types"
|
||||
|
||||
export const MAIL_PERMISSIONS: PermissionKey[] = [
|
||||
"mail.access",
|
||||
"mail.messages.read",
|
||||
"mail.messages.send",
|
||||
"mail.messages.drafts",
|
||||
"mail.messages.schedule",
|
||||
"mail.messages.organize",
|
||||
"mail.labels.manage",
|
||||
"mail.attachments.download",
|
||||
"mail.contacts.manage",
|
||||
"mail.signatures.manage",
|
||||
"mail.rules.manage",
|
||||
"mail.blocked_senders.manage",
|
||||
"mail.stats.view",
|
||||
"mail.mailboxes.apply",
|
||||
]
|
||||
|
||||
export const ADMIN_PERMISSIONS: PermissionKey[] = [
|
||||
"admin.overview.view",
|
||||
"admin.users.view",
|
||||
"admin.users.create",
|
||||
"admin.users.update",
|
||||
"admin.users.delete",
|
||||
"admin.users.reset_password",
|
||||
"admin.permission_groups.view",
|
||||
"admin.permission_groups.create",
|
||||
"admin.permission_groups.update",
|
||||
"admin.permission_groups.delete",
|
||||
"admin.domains.view",
|
||||
"admin.domains.create",
|
||||
"admin.domains.update",
|
||||
"admin.domains.delete",
|
||||
"admin.dns.view",
|
||||
"admin.dns.check",
|
||||
"admin.mailboxes.view",
|
||||
"admin.mailboxes.create",
|
||||
"admin.mailboxes.update",
|
||||
"admin.mailboxes.delete",
|
||||
"admin.aliases.view",
|
||||
"admin.aliases.create",
|
||||
"admin.aliases.update",
|
||||
"admin.aliases.delete",
|
||||
"admin.messages.view",
|
||||
"admin.messages.read",
|
||||
"admin.messages.attachments",
|
||||
"admin.settings.view",
|
||||
"admin.settings.update",
|
||||
"admin.settings.test_smtp",
|
||||
"admin.templates.view",
|
||||
"admin.templates.update",
|
||||
"admin.templates.reset",
|
||||
]
|
||||
|
||||
export function hasPermission(user: User | undefined | null, permission: PermissionKey) {
|
||||
if (!user) return false
|
||||
if (user.role === "admin") return true
|
||||
return (user.permissions || []).includes(permission)
|
||||
}
|
||||
|
||||
export function hasAnyPermission(user: User | undefined | null, permissions: PermissionKey[]) {
|
||||
if (!user) return false
|
||||
if (user.role === "admin") return true
|
||||
return permissions.some((permission) => (user.permissions || []).includes(permission))
|
||||
}
|
||||
|
||||
export function hasAdminAccess(user: User | undefined | null) {
|
||||
return hasAnyPermission(user, ADMIN_PERMISSIONS)
|
||||
}
|
||||
|
||||
export function hasMailAccess(user: User | undefined | null) {
|
||||
return hasPermission(user, "mail.access")
|
||||
}
|
||||
@@ -84,3 +84,27 @@ export function decodeMimeHeader(value: string): string {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export interface LabelColorStyle {
|
||||
/** CSS background-color value (HSL string) */
|
||||
backgroundColor: string
|
||||
/** CSS color value for text — always high contrast against the background */
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic, high-contrast color pair for a label name.
|
||||
* Uses FNV-1a hash with position-dependent mixing → HSL mapping.
|
||||
* Text is always white (lightness 45 % guarantees dark-enough background).
|
||||
*/
|
||||
export function generateLabelColor(name: string): LabelColorStyle {
|
||||
// FNV-1a with 32-bit offset basis and prime
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
h ^= name.charCodeAt(i) + i * 0x01000193 // position-dependent seed
|
||||
h = Math.imul(h, 0x01000193)
|
||||
h ^= h >>> 16
|
||||
}
|
||||
const hue = ((h >>> 0) % 360 + 360) % 360
|
||||
return { backgroundColor: `hsl(${hue}, 70%, 45%)`, color: "#ffffff" }
|
||||
}
|
||||
|
||||
+536
-79
@@ -2,8 +2,8 @@ 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, Circle, Copy, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, SystemSettings } from "@/lib/api"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -20,14 +20,18 @@ 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 { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "settings"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionLabels: Record<Section, string> = {
|
||||
overview: "概览",
|
||||
users: "用户",
|
||||
permissionGroups: "权限组",
|
||||
domains: "域名",
|
||||
mailboxes: "邮箱账号",
|
||||
aliases: "别名转发",
|
||||
@@ -35,25 +39,51 @@ const sectionLabels: Record<Section, string> = {
|
||||
settings: "系统设置",
|
||||
}
|
||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||
const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
overview: ["admin.overview.view"],
|
||||
users: ["admin.users.view"],
|
||||
permissionGroups: ["admin.permission_groups.view"],
|
||||
domains: ["admin.domains.view", "admin.dns.view"],
|
||||
mailboxes: ["admin.mailboxes.view"],
|
||||
aliases: ["admin.aliases.view"],
|
||||
messages: ["admin.messages.view"],
|
||||
settings: ["admin.settings.view", "admin.templates.view"],
|
||||
}
|
||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||||
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, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
|
||||
export function AdminPage() {
|
||||
const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview })
|
||||
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users })
|
||||
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains })
|
||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes })
|
||||
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases })
|
||||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings })
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const canOverview = hasPermission(user, "admin.overview.view")
|
||||
const canUsersView = hasPermission(user, "admin.users.view")
|
||||
const canPermissionGroupsView = hasPermission(user, "admin.permission_groups.view")
|
||||
const canDomainsView = hasPermission(user, "admin.domains.view")
|
||||
const canDNSView = hasPermission(user, "admin.dns.view")
|
||||
const canMailboxesView = hasPermission(user, "admin.mailboxes.view")
|
||||
const canAliasesView = hasPermission(user, "admin.aliases.view")
|
||||
const canMessagesView = hasPermission(user, "admin.messages.view")
|
||||
const canSettingsView = hasPermission(user, "admin.settings.view")
|
||||
const canTemplatesView = hasPermission(user, "admin.templates.view")
|
||||
const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview, enabled: !!user && canOverview })
|
||||
const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) })
|
||||
const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) })
|
||||
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) })
|
||||
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView) })
|
||||
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 domainItems = domains.data?.items || []
|
||||
const mailboxItems = mailboxes.data?.items || []
|
||||
const aliasItems = aliases.data?.items || []
|
||||
const userItems = users.data?.items || []
|
||||
const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== "pg_super_admin" && group.id !== "pg_regular_user")
|
||||
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
||||
const rawSection = params.get("section") as Section | null
|
||||
const section: Section = rawSection && sectionKeys.includes(rawSection) ? rawSection : "overview"
|
||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||||
@@ -62,7 +92,7 @@ export function AdminPage() {
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{sectionLabels[section]}</h1>
|
||||
</div>
|
||||
|
||||
{section === "overview" && (
|
||||
{section === "overview" && canOverview && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<Stat icon={<Users />} label="用户" value={overview.data?.users || 0} />
|
||||
<Stat icon={<Globe2 />} label="域名" value={overview.data?.domains || 0} />
|
||||
@@ -71,8 +101,9 @@ export function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
||||
{section === "users" && <UsersSection users={userItems} />}
|
||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
||||
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} />}
|
||||
{section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
|
||||
{section === "domains" && <DomainsSection domains={domainItems} />}
|
||||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
@@ -82,8 +113,8 @@ export function AdminPage() {
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
function OverviewSection({ overview, domains, settings, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; onSectionChange: (section: Section) => void }) {
|
||||
const checklist = setupChecklist(overview, domains, settings)
|
||||
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
|
||||
const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section))
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
@@ -142,7 +173,7 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
|
||||
return [
|
||||
{ key: "domain", title: "添加邮件域名", detail: hasDomain ? `${domains.length} 个域名已添加` : "先添加 example.com 这样的邮件域名", done: hasDomain, section: "domains" as Section },
|
||||
{ key: "dns", title: "完成 DNS 检测", detail: dnsReady ? "至少一个域名 DNS 正常" : "配置 MX、SPF、DKIM、DMARC 后执行检测", done: dnsReady, section: "domains" as Section },
|
||||
{ key: "mailbox", title: "创建邮箱账号", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给管理员或用户创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
|
||||
{ key: "mailbox", title: "创建邮箱账号", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给超级管理员或普通用户创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
|
||||
{ key: "smtp", title: "确认发信链路", detail: settings?.smtpHost ? `内置 Postfix:${settings.smtpHost}:${settings.smtpPort}` : "默认使用内置 Postfix", done: true, section: "settings" as Section },
|
||||
{ key: "mail", title: "完成收发测试", detail: hasMail ? `${overview?.messages || 0} 封邮件已入库` : "发送或接收一封测试邮件", done: hasMail, section: "messages" as Section },
|
||||
]
|
||||
@@ -152,13 +183,17 @@ function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
|
||||
}
|
||||
|
||||
function UsersSection({ users }: { users: AdminUser[] }) {
|
||||
function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permissionGroups: PermissionGroup[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [roleFilter, setRoleFilter] = React.useState("all")
|
||||
const [statusFilter, setStatusFilter] = React.useState("all")
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const canCreate = hasPermission(user, "admin.users.create")
|
||||
const canDelete = hasPermission(user, "admin.users.delete")
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
|
||||
@@ -172,7 +207,7 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>用户管理</CardTitle>
|
||||
<CreateUserDialog />
|
||||
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -185,7 +220,7 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
||||
<SelectTrigger className="lg:w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
<SelectItem value="admin">超级管理员</SelectItem>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -206,20 +241,21 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
||||
<div className="truncate font-medium">{user.displayName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{user.email}</div>
|
||||
</div>
|
||||
<UserActions user={user} onDelete={() => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} />
|
||||
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
|
||||
<RoleBadge user={user} />
|
||||
<Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge>
|
||||
<Badge variant="outline">{new Date(user.createdAt).toLocaleDateString()}</Badge>
|
||||
</div>
|
||||
<div className="mt-3"><UserPermissionGroupsCell user={user} /></div>
|
||||
<div className="mt-3"><UserMailboxCell user={user} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>用户</TableHead><TableHead>角色</TableHead><TableHead>邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建时间</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||
<TableHeader><TableRow><TableHead>用户</TableHead><TableHead>身份</TableHead><TableHead>权限组</TableHead><TableHead>邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建时间</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
@@ -227,11 +263,12 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">{user.email}</div>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge></TableCell>
|
||||
<TableCell><RoleBadge user={user} /></TableCell>
|
||||
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
|
||||
<TableCell><UserMailboxCell user={user} /></TableCell>
|
||||
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
|
||||
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell><UserActions user={user} onDelete={() => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) })} /></TableCell>
|
||||
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -244,10 +281,281 @@ function UsersSection({ users }: { users: AdminUser[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[]; catalog: PermissionInfo[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [editing, setEditing] = React.useState<PermissionGroup | null>(null)
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const canCreate = hasPermission(user, "admin.permission_groups.create")
|
||||
const canUpdate = hasPermission(user, "admin.permission_groups.update")
|
||||
const canDelete = hasPermission(user, "admin.permission_groups.delete")
|
||||
const remove = useMutation({
|
||||
mutationFn: api.deletePermissionGroup,
|
||||
onSuccess: () => {
|
||||
setPendingConfirm(null)
|
||||
invalidateAdmin(qc)
|
||||
toast({ title: "权限组已删除" })
|
||||
},
|
||||
onError: (e) => toast({ title: "删除失败", description: e.message }),
|
||||
})
|
||||
const filtered = groups.filter((group) => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
if (!keyword) return true
|
||||
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
|
||||
})
|
||||
const isEditable = (group: PermissionGroup) => group.id !== "pg_super_admin"
|
||||
const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>权限组管理</CardTitle>
|
||||
{canCreate && <PermissionGroupDialog catalog={catalog} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限组、说明或权限键" className="pl-9" />
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{filtered.map((group) => (
|
||||
<div key={group.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium">{group.name}</div>
|
||||
{group.system && <Badge variant="outline">系统组</Badge>}
|
||||
{!group.system && <Badge variant="secondary">自定义</Badge>}
|
||||
<Badge variant="outline">{group.userCount} 人</Badge>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限组</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
disabled={!isDeletable(group) || !canDelete}
|
||||
onSelect={() => setPendingConfirm({ title: "删除权限组?", description: `${group.name} 删除后不能再分配给用户。`, confirmText: "删除权限组", onConfirm: () => remove.mutate(group.id) })}
|
||||
>
|
||||
删除权限组
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>}
|
||||
</div>
|
||||
<PermissionBadges permissions={group.permissions} catalog={catalog} />
|
||||
<PermissionLimitBadges limits={group.limits} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && <Empty text="暂无匹配的权限组" />}
|
||||
</CardContent>
|
||||
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
|
||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: PermissionGroup; catalog: PermissionInfo[]; open?: boolean; onOpenChange?: (open: boolean) => void }) {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [internalOpen, setInternalOpen] = React.useState(false)
|
||||
const dialogOpen = open ?? internalOpen
|
||||
const setDialogOpen = onOpenChange ?? setInternalOpen
|
||||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
|
||||
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
|
||||
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
|
||||
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultPermissionLimits)
|
||||
React.useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setPermissions(group?.permissions || [])
|
||||
setLimits(group?.limits || defaultPermissionLimits)
|
||||
}
|
||||
}, [dialogOpen, group])
|
||||
const mutation = useMutation({
|
||||
mutationFn: (form: FormData) => {
|
||||
const payload = {
|
||||
name: String(form.get("name") || ""),
|
||||
description: String(form.get("description") || ""),
|
||||
permissions,
|
||||
limits,
|
||||
}
|
||||
return group ? api.updatePermissionGroup(group.id, payload) : api.createPermissionGroup(payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateAdmin(qc)
|
||||
setDialogOpen(false)
|
||||
toast({ title: group ? "权限组已更新" : "权限组已创建" })
|
||||
},
|
||||
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
|
||||
})
|
||||
const trigger = group ? null : (
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="h-4 w-4" />权限组</Button>
|
||||
</DialogTrigger>
|
||||
)
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger}
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader><DialogTitle>{group ? "编辑权限组" : "创建权限组"}</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
|
||||
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} />
|
||||
</div>
|
||||
<PermissionLimitEditor value={limits} onChange={setLimits} />
|
||||
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
|
||||
<DialogFooter><Button disabled={mutation.isPending}>{mutation.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInfo[]; value: PermissionKey[]; onChange: (value: PermissionKey[]) => void }) {
|
||||
const grouped = groupPermissionCatalog(catalog)
|
||||
function toggle(permission: PermissionKey, checked: boolean) {
|
||||
onChange(checked ? Array.from(new Set([...value, permission])) : value.filter((item) => item !== permission))
|
||||
}
|
||||
function toggleCategory(items: PermissionInfo[], checked: boolean) {
|
||||
const keys = items.map((item) => item.key)
|
||||
onChange(checked ? Array.from(new Set([...value, ...keys])) : value.filter((item) => !keys.includes(item)))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>菜单与操作权限</Label>
|
||||
<Badge variant="outline">{value.length} 项</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{grouped.map(({ category, items }) => {
|
||||
const allChecked = items.every((item) => value.includes(item.key))
|
||||
return (
|
||||
<div key={category} className="rounded-lg border">
|
||||
<div className="flex items-center justify-between gap-3 border-b px-3 py-2">
|
||||
<label className="flex items-center gap-2 font-medium">
|
||||
<Checkbox checked={allChecked} onCheckedChange={(next) => toggleCategory(items, next === true)} />
|
||||
{category}
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">{items.filter((item) => value.includes(item.key)).length}/{items.length}</span>
|
||||
</div>
|
||||
<div className="grid gap-2 p-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<label key={item.key} className="flex min-h-16 items-start gap-3 rounded-md border px-3 py-2">
|
||||
<Checkbox checked={value.includes(item.key)} onCheckedChange={(next) => toggle(item.key, next === true)} />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{item.label}</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">{item.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; onChange: (value: PermissionLimits) => void }) {
|
||||
function update(key: keyof PermissionLimits, raw: string) {
|
||||
const next = Number(raw)
|
||||
onChange({ ...value, [key]: Number.isFinite(next) && next > 0 ? Math.floor(next) : 0 })
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label>账号配额</Label>
|
||||
<span className="text-xs text-muted-foreground">填 0 表示不限制</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>附件上限 MB</Label>
|
||||
<Input type="number" min={0} value={value.maxAttachmentMb} onChange={(event) => update("maxAttachmentMb", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>SMTP 每日封数</Label>
|
||||
<Input type="number" min={0} value={value.smtpDailyLimit} onChange={(event) => update("smtpDailyLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>SMTP 每分钟封数</Label>
|
||||
<Input type="number" min={0} value={value.smtpMinuteLimit} onChange={(event) => update("smtpMinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>IMAP 每分钟命令数</Label>
|
||||
<Input type="number" min={0} value={value.imapMinuteLimit} onChange={(event) => update("imapMinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>POP3 每分钟命令数</Label>
|
||||
<Input type="number" min={0} value={value.pop3MinuteLimit} onChange={(event) => update("pop3MinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) {
|
||||
const labelByKey = new Map(catalog.map((item) => [item.key, item.label]))
|
||||
if (permissions.length === 0) return <div className="mt-3 text-sm text-muted-foreground">无后台权限</div>
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{permissions.slice(0, 10).map((permission) => (
|
||||
<Badge key={permission} variant="outline" className="font-normal">{labelByKey.get(permission) || permission}</Badge>
|
||||
))}
|
||||
{permissions.length > 10 && <Badge variant="secondary">+{permissions.length - 10}</Badge>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
|
||||
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits })
|
||||
const value = limits || defaultLimitsQuery.data || defaultPermissionLimits
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary" className="font-normal">附件 {limitText(value.maxAttachmentMb, "MB")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">SMTP 每日 {limitText(value.smtpDailyLimit, "封")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">SMTP 每分钟 {limitText(value.smtpMinuteLimit, "封")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">IMAP 每分钟 {limitText(value.imapMinuteLimit, "次")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">POP3 每分钟 {limitText(value.pop3MinuteLimit, "次")}</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function limitText(value: number, unit: string) {
|
||||
return value > 0 ? `${value} ${unit}` : "不限"
|
||||
}
|
||||
|
||||
function groupPermissionCatalog(catalog: PermissionInfo[]) {
|
||||
const order: string[] = []
|
||||
const grouped = new Map<string, PermissionInfo[]>()
|
||||
for (const item of catalog) {
|
||||
if (!grouped.has(item.category)) {
|
||||
grouped.set(item.category, [])
|
||||
order.push(item.category)
|
||||
}
|
||||
grouped.get(item.category)!.push(item)
|
||||
}
|
||||
return order.map((category) => ({ category, items: grouped.get(category)! }))
|
||||
}
|
||||
|
||||
function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const canCreate = hasPermission(user, "admin.domains.create")
|
||||
const canUpdate = hasPermission(user, "admin.domains.update")
|
||||
const canDelete = hasPermission(user, "admin.domains.delete")
|
||||
const canViewDNS = hasPermission(user, "admin.dns.view")
|
||||
const update = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => api.updateDomain(id, { status }), onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||
const remove = useMutation({ mutationFn: api.deleteDomain, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "域名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||
return (
|
||||
@@ -255,7 +563,7 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>域名管理</CardTitle>
|
||||
<CreateDomainDialog />
|
||||
{canCreate && <CreateDomainDialog />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -268,9 +576,9 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={domain.status === "active" ? "default" : "secondary"}>{domain.status === "active" ? "启用" : "停用"}</Badge>
|
||||
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
|
||||
<DomainDNSDialog domain={domain} />
|
||||
<Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、别名和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" />删除</Button>
|
||||
{canViewDNS && <DomainDNSDialog domain={domain} />}
|
||||
{canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>}
|
||||
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、别名和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" />删除</Button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -296,16 +604,21 @@ function DomainDNSDialog({ domain }: { domain: Domain }) {
|
||||
}
|
||||
|
||||
function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxType[]; users: AdminUser[]; domains: Domain[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const canCreate = hasPermission(user, "admin.mailboxes.create")
|
||||
const canUpdate = hasPermission(user, "admin.mailboxes.update")
|
||||
const canDelete = hasPermission(user, "admin.mailboxes.delete")
|
||||
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>邮箱账号管理</CardTitle>
|
||||
<CreateMailboxDialog domains={domains} users={users} />
|
||||
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -317,7 +630,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
<div className="truncate font-medium">{mailbox.address}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{mailbox.userEmail || mailbox.userId}</div>
|
||||
</div>
|
||||
<MailboxActions mailbox={mailbox} users={users} onDelete={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} />
|
||||
<MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge>
|
||||
@@ -338,7 +651,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
<TableCell>{mailbox.displayName}</TableCell>
|
||||
<TableCell>{mailbox.quotaMb} MB</TableCell>
|
||||
<TableCell><Badge variant={mailbox.status === "active" ? "default" : "secondary"}>{mailbox.status === "active" ? "启用" : "停用"}</Badge></TableCell>
|
||||
<TableCell><MailboxActions mailbox={mailbox} users={users} onDelete={() => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) })} /></TableCell>
|
||||
<TableCell><MailboxActions mailbox={mailbox} users={users} canUpdate={canUpdate} onDelete={canDelete ? () => setPendingConfirm({ title: "删除邮箱?", description: `将删除 ${mailbox.address} 和其中邮件。`, confirmText: "删除邮箱", onConfirm: () => remove.mutate(mailbox.id) }) : undefined} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -352,9 +665,14 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
}
|
||||
|
||||
function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const canCreate = hasPermission(user, "admin.aliases.create")
|
||||
const canUpdate = hasPermission(user, "admin.aliases.update")
|
||||
const canDelete = hasPermission(user, "admin.aliases.delete")
|
||||
const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||
const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||
return (
|
||||
@@ -362,7 +680,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>别名/转发管理</CardTitle>
|
||||
<CreateAliasDialog domains={domains} />
|
||||
{canCreate && <CreateAliasDialog domains={domains} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -374,7 +692,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
||||
<div className="truncate font-medium">{alias.source}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{alias.destination}</div>
|
||||
</div>
|
||||
<AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) })} />
|
||||
<AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) }) : undefined} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge>
|
||||
@@ -393,7 +711,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
||||
<TableCell>{alias.destination}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell>
|
||||
<TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell>
|
||||
<TableCell><AliasActions alias={alias} onToggle={() => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) })} /></TableCell>
|
||||
<TableCell><AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) }) : undefined} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -534,9 +852,17 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates })
|
||||
const canSettingsView = hasPermission(user, "admin.settings.view")
|
||||
const canUpdateSettings = hasPermission(user, "admin.settings.update")
|
||||
const canTestSMTP = hasPermission(user, "admin.settings.test_smtp")
|
||||
const canViewTemplates = hasPermission(user, "admin.templates.view")
|
||||
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
@@ -617,16 +943,22 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
settings.reservedMailboxPrefixes,
|
||||
].join("|") : "loading"
|
||||
const tabs: { key: typeof settingsTab; label: string }[] = [
|
||||
{ key: "base", label: "基础" },
|
||||
{ key: "smtp", label: "SMTP" },
|
||||
{ key: "storage", label: "存储" },
|
||||
{ key: "mail", label: "邮件" },
|
||||
{ key: "templates", label: "模板" },
|
||||
{ key: "security", label: "安全" },
|
||||
...(canSettingsView ? [
|
||||
{ key: "base" as const, label: "基础" },
|
||||
{ key: "smtp" as const, label: "SMTP" },
|
||||
{ key: "storage" as const, label: "存储" },
|
||||
{ key: "mail" as const, label: "邮件" },
|
||||
] : []),
|
||||
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||||
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
|
||||
{ key: "about", label: "关于" },
|
||||
]
|
||||
React.useEffect(() => {
|
||||
if (tabs.some((tab) => tab.key === settingsTab)) return
|
||||
setSettingsTab(tabs[0]?.key || "about")
|
||||
}, [settingsTab, tabs])
|
||||
return (
|
||||
<form key={formKey} onSubmit={(event) => { event.preventDefault(); save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
|
||||
<form key={formKey} onSubmit={(event) => { event.preventDefault(); if (canUpdateSettings) save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
|
||||
<div className="flex flex-wrap gap-2 rounded-lg border bg-card p-2">
|
||||
{tabs.map((tab) => (
|
||||
<Button key={tab.key} type="button" variant={settingsTab === tab.key ? "default" : "ghost"} size="sm" onClick={() => setSettingsTab(tab.key)}>
|
||||
@@ -652,7 +984,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
<div>
|
||||
<CardTitle>发信通道</CardTitle>
|
||||
</div>
|
||||
<TestSMTPDialog disabled={!settings} />
|
||||
{canTestSMTP && <TestSMTPDialog disabled={!settings} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -721,7 +1053,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "templates" && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} />}
|
||||
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
||||
|
||||
{settingsTab === "security" && <Card>
|
||||
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
||||
@@ -742,14 +1074,54 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
|
||||
{settingsTab === "about" && <AboutProjectCard />}
|
||||
|
||||
{settingsTab !== "about" && <div className="flex justify-end">
|
||||
{settingsTab !== "about" && canUpdateSettings && <div className="flex justify-end">
|
||||
<Button disabled={save.isPending || !settings}>{save.isPending ? "保存中..." : "保存设置"}</Button>
|
||||
</div>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
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/LanQin996/LanQin-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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -758,12 +1130,25 @@ function AboutProjectCard() {
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<AboutRow label="版本">
|
||||
{projectTag ? (
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectReleaseUrl} target="_blank" rel="noreferrer">
|
||||
<GitBranch className="h-5 w-5 text-primary" />
|
||||
{projectTag}
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectReleaseUrl} target="_blank" rel="noreferrer">
|
||||
<GitBranch className="h-5 w-5 text-primary" />
|
||||
{projectTag}
|
||||
</a>
|
||||
</Button>
|
||||
{updateAvailable && latestRelease.data && (
|
||||
<Button type="button" variant="default" className="h-11 px-4 text-base font-normal" asChild>
|
||||
<a href={latestRelease.data.html_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
新版本 {latestRelease.data.tag_name}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{latestRelease.isLoading && (
|
||||
<span className="text-xs text-muted-foreground">检查更新中...</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" disabled>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
@@ -852,7 +1237,7 @@ function TestSMTPDialog({ disabled }: { disabled?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MailTemplatesPanel({ templates, loading }: { templates: MailTemplate[]; loading: boolean }) {
|
||||
function MailTemplatesPanel({ templates, loading, canUpdate, canReset }: { templates: MailTemplate[]; loading: boolean; canUpdate: boolean; canReset: boolean }) {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [selectedKey, setSelectedKey] = React.useState("")
|
||||
@@ -906,14 +1291,14 @@ function MailTemplatesPanel({ templates, loading }: { templates: MailTemplate[];
|
||||
<Textarea value={bodyHtml} onChange={(event) => setBodyHtml(event.target.value)} className="min-h-64 font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" disabled={reset.isPending || save.isPending} onClick={() => reset.mutate()}>
|
||||
{(canUpdate || canReset) && <div className="flex justify-end gap-2">
|
||||
{canReset && <Button type="button" variant="outline" disabled={reset.isPending || save.isPending} onClick={() => reset.mutate()}>
|
||||
{reset.isPending ? "恢复中..." : "恢复默认"}
|
||||
</Button>
|
||||
<Button type="button" disabled={save.isPending || reset.isPending} onClick={() => save.mutate()}>
|
||||
</Button>}
|
||||
{canUpdate && <Button type="button" disabled={save.isPending || reset.isPending} onClick={() => save.mutate()}>
|
||||
{save.isPending ? "保存中..." : "保存模板"}
|
||||
</Button>
|
||||
</div>
|
||||
</Button>}
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -1004,31 +1389,96 @@ function UserMailboxCell({ user }: { user: AdminUser }) {
|
||||
)
|
||||
}
|
||||
|
||||
function UserActions({ user, onDelete }: { user: AdminUser; onDelete: () => void }) {
|
||||
function UserPermissionGroupsCell({ user }: { user: AdminUser }) {
|
||||
const groups = user.permissionGroups || []
|
||||
if (groups.length === 0) return <span className="text-muted-foreground">普通用户</span>
|
||||
return (
|
||||
<div className="flex max-w-md flex-wrap gap-1">
|
||||
{groups.map((group) => (
|
||||
<Badge key={group.id} variant={group.id === "pg_super_admin" ? "default" : "secondary"} className="font-normal">
|
||||
{group.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function assignableUserGroupIDs(user: AdminUser) {
|
||||
return (user.permissionGroupIds || []).filter((id) => id !== "pg_super_admin" && id !== "pg_regular_user")
|
||||
}
|
||||
|
||||
function PermissionGroupPicker({ groups, value, onChange }: { groups: PermissionGroup[]; value: string[]; onChange: (value: string[]) => void }) {
|
||||
function toggle(groupID: string, checked: boolean) {
|
||||
onChange(checked ? Array.from(new Set([...value, groupID])) : value.filter((id) => id !== groupID))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>权限组</Label>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{groups.map((group) => {
|
||||
const checked = value.includes(group.id)
|
||||
return (
|
||||
<label key={group.id} className="flex min-h-16 items-start gap-3 rounded-md border px-3 py-2">
|
||||
<Checkbox checked={checked} onCheckedChange={(next) => toggle(group.id, next === true)} />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{group.name}</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">{group.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{groups.length === 0 && <Empty text="暂无可分配权限组" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleBadge({ user }: { user: AdminUser }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge>
|
||||
{user.protected && <Badge variant="outline">默认账号</Badge>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; permissionGroups: PermissionGroup[]; onDelete?: () => void }) {
|
||||
const me = useMe()
|
||||
const currentUser = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [editOpen, setEditOpen] = React.useState(false)
|
||||
const [passwordOpen, setPasswordOpen] = React.useState(false)
|
||||
const canUpdate = hasPermission(currentUser, "admin.users.update")
|
||||
const canResetPassword = hasPermission(currentUser, "admin.users.reset_password")
|
||||
const update = useMutation({
|
||||
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => api.updateUser(user.id, payload),
|
||||
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload),
|
||||
onSuccess: () => { invalidateAdmin(qc); toast({ title: "用户已更新" }) },
|
||||
onError: (e) => toast({ title: "更新失败", description: e.message }),
|
||||
})
|
||||
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) {
|
||||
update.mutate({ displayName: user.displayName, role: patch.role || user.role, disabled: patch.disabled ?? user.disabled })
|
||||
const role = patch.role || user.role
|
||||
update.mutate({
|
||||
displayName: user.displayName,
|
||||
role,
|
||||
disabled: patch.disabled ?? user.disabled,
|
||||
permissionGroupIds: role === "user" ? assignableUserGroupIDs(user) : [],
|
||||
})
|
||||
}
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑用户</DropdownMenuItem><DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用用户" : "停用用户"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为管理员"}</DropdownMenuItem><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除用户</DropdownMenuItem></DropdownMenuContent></DropdownMenu><EditUserDialog user={user} open={editOpen} onOpenChange={setEditOpen} /><ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} /></>
|
||||
if (!canUpdate && !canResetPassword && !onDelete) return null
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑用户</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用用户" : "停用用户"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为超级管理员"}</DropdownMenuItem></>}{!user.protected && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除用户</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||
}
|
||||
|
||||
function CreateUserDialog() {
|
||||
function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [role, setRole] = React.useState<"admin" | "user">("user")
|
||||
const [status, setStatus] = React.useState("active")
|
||||
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
|
||||
const create = useMutation({
|
||||
mutationFn: (form: FormData) => api.createUser({ email: String(form.get("email") || ""), displayName: String(form.get("displayName") || ""), password: String(form.get("password") || ""), role, disabled: status === "disabled" }),
|
||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "用户已创建" }) },
|
||||
mutationFn: (form: FormData) => api.createUser({ email: String(form.get("email") || ""), displayName: String(form.get("displayName") || ""), password: String(form.get("password") || ""), role, disabled: status === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }),
|
||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "用户已创建" }) },
|
||||
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
||||
})
|
||||
return (
|
||||
@@ -1041,9 +1491,10 @@ function CreateUserDialog() {
|
||||
<Field name="displayName" label="显示名称" placeholder="用户名称" />
|
||||
<Field name="password" label="初始密码" type="password" minLength={8} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectField label="角色" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "超级管理员"]]} />
|
||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
||||
</div>
|
||||
{role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
||||
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -1051,20 +1502,22 @@ function CreateUserDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
function MailboxActions({ mailbox, users, onDelete }: { mailbox: MailboxType; users: AdminUser[]; onDelete: () => void }) {
|
||||
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem></DropdownMenuContent></DropdownMenu><EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} /></>
|
||||
if (!canUpdate && !onDelete) return null
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
||||
}
|
||||
|
||||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle: () => void; onDelete: () => void }) {
|
||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除别名</DropdownMenuItem></DropdownMenuContent></DropdownMenu>
|
||||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
||||
if (!onToggle && !onDelete) return null
|
||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除别名</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||
}
|
||||
|
||||
function EditUserDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [role, setRole] = React.useState(user.role); const [disabled, setDisabled] = React.useState(user.disabled ? "disabled" : "active")
|
||||
React.useEffect(() => { setRole(user.role); setDisabled(user.disabled ? "disabled" : "active") }, [user, open])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled" }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "用户已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>编辑用户</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="email" label="登录邮箱" value={user.email} readOnly /><Field name="displayName" label="显示名称" defaultValue={user.displayName} /><div className="grid grid-cols-2 gap-3"><SelectField label="角色" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[['user','普通用户'],['admin','管理员']]} /><SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[['active','正常'],['disabled','停用']]} /></div><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [role, setRole] = React.useState(user.role); const [disabled, setDisabled] = React.useState(user.disabled ? "disabled" : "active"); const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>(assignableUserGroupIDs(user))
|
||||
React.useEffect(() => { setRole(user.role); setDisabled(user.disabled ? "disabled" : "active"); setPermissionGroupIds(assignableUserGroupIDs(user)) }, [user, open])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "用户已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>编辑用户</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="email" label="登录邮箱" value={user.email} readOnly /><Field name="displayName" label="显示名称" defaultValue={user.displayName} /><div className="grid grid-cols-2 gap-3"><SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[['user','普通用户'],['admin','超级管理员']]} disabled={user.protected} /><SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[['active','正常'],['disabled','停用']]} disabled={user.protected} /></div>{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
@@ -1089,7 +1542,7 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
|
||||
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerEmail: String(form.get("ownerEmail") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱账号</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="账号" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按邮箱匹配用户'],['existing','追加到已有用户']]} />{ownerMode === "existing" ? <SelectField label="已有用户" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /> : <Field name="ownerEmail" label="归属用户邮箱" placeholder="留空则使用新邮箱" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="角色" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱账号</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="账号" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按邮箱匹配用户'],['existing','追加到已有用户']]} />{ownerMode === "existing" ? <SelectField label="已有用户" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /> : <Field name="ownerEmail" label="归属用户邮箱" placeholder="留空则使用新邮箱" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','超级管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||||
@@ -1100,6 +1553,9 @@ function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||||
}
|
||||
|
||||
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const canCheckDNS = hasPermission(user, "admin.dns.check")
|
||||
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
|
||||
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) } })
|
||||
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
||||
@@ -1111,8 +1567,9 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"><CheckCircle2 className="h-4 w-4" />检测结果</div>
|
||||
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <div key={k} className="flex items-center gap-2 text-sm"><CheckCircle2 className={`h-4 w-4 shrink-0 ${v.ok ? "text-green-600" : "text-destructive"}`} /><span className="font-medium">{k.toUpperCase()}:</span> {v.message}</div>)}</div>
|
||||
</>}</>
|
||||
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle><Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button></div>
|
||||
if (embedded) return <div className="space-y-4"><div className="flex items-center justify-between"><div className="font-medium">DNS 记录</div><Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button></div>{content}</div>
|
||||
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button> : null
|
||||
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</div>
|
||||
if (embedded) return <div className="space-y-4"><div className="flex items-center justify-between"><div className="font-medium">DNS 记录</div>{checkButton}</div>{content}</div>
|
||||
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
||||
}
|
||||
|
||||
@@ -1165,7 +1622,7 @@ function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label:
|
||||
)
|
||||
}
|
||||
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
|
||||
function SelectField({ label, value, onValueChange, items }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][] }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
||||
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
||||
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
||||
|
||||
|
||||
|
||||
+391
-174
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats } from "@/lib/api"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -12,6 +12,7 @@ import { useMe } from "@/hooks/use-me"
|
||||
import { useLogout } from "@/hooks/use-logout"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { validatePasswordConfirm } from "@/lib/validation"
|
||||
import { hasPermission } from "@/lib/permissions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -67,19 +68,41 @@ export function ProfilePage() {
|
||||
const themeMountedRef = React.useRef(false)
|
||||
|
||||
const rawTab = params.get("tab") as Tab | null
|
||||
const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile"
|
||||
const user = me.data?.user
|
||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
|
||||
const canAccessMail = hasPermission(user, "mail.access")
|
||||
const canReadMail = hasPermission(user, "mail.messages.read")
|
||||
const canOrganizeMail = hasPermission(user, "mail.messages.organize")
|
||||
const canManageLabels = hasPermission(user, "mail.labels.manage")
|
||||
const canManageContacts = hasPermission(user, "mail.contacts.manage")
|
||||
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
|
||||
const canManageRules = hasPermission(user, "mail.rules.manage")
|
||||
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
|
||||
const canViewStats = hasPermission(user, "mail.stats.view")
|
||||
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
|
||||
const visibleTabKeys = tabKeys.filter((key) => {
|
||||
if (key === "profile") return true
|
||||
if (key === "mailboxes") return canAccessMail || canApplyMailbox
|
||||
if (key === "clients") return canAccessMail
|
||||
if (key === "signatures") return canManageSignatures
|
||||
if (key === "contacts") return canManageContacts
|
||||
if (key === "cleanup") return canOrganizeMail
|
||||
if (key === "rules") return canManageRules
|
||||
if (key === "blocked") return canManageBlocked
|
||||
if (key === "stats") return canViewStats
|
||||
return false
|
||||
})
|
||||
const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile"
|
||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
|
||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
|
||||
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 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 || ""
|
||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
|
||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
|
||||
|
||||
const profile = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
|
||||
@@ -191,7 +214,11 @@ export function ProfilePage() {
|
||||
|
||||
const logout = useLogout()
|
||||
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
|
||||
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }); setMobileSidebarOpen(false) }
|
||||
function setTab(next: Tab) {
|
||||
const visibleNext = visibleTabKeys.includes(next) ? next : "profile"
|
||||
setParams(visibleNext === "profile" ? {} : { tab: visibleNext })
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
|
||||
if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">加载中...</div>
|
||||
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
||||
@@ -205,7 +232,7 @@ export function ProfilePage() {
|
||||
<SidebarGroup>
|
||||
{!sidebarCollapsed && <SidebarGroupLabel>个人中心</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>{tabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
|
||||
<SidebarMenu>{visibleTabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
@@ -264,21 +291,39 @@ export function ProfilePage() {
|
||||
</div>
|
||||
)
|
||||
function renderTab() {
|
||||
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
|
||||
if (tab === "mailboxes") return <MailboxManagement mailboxes={canAccessMail ? mailboxes.data?.items || [] : []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
|
||||
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
|
||||
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
|
||||
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
||||
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
|
||||
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
|
||||
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
|
||||
}
|
||||
}
|
||||
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账号配额</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<LimitBadge label="附件上限" value={user.limits?.maxAttachmentMb} unit="MB" />
|
||||
<LimitBadge label="SMTP 每日" value={user.limits?.smtpDailyLimit} unit="封" />
|
||||
<LimitBadge label="SMTP 每分钟" value={user.limits?.smtpMinuteLimit} unit="封" />
|
||||
<LimitBadge label="IMAP 每分钟" value={user.limits?.imapMinuteLimit} unit="次" />
|
||||
<LimitBadge label="POP3 每分钟" value={user.limits?.pop3MinuteLimit} unit="次" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showStats && <StatsSummary stats={stats} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账户信息</CardTitle>
|
||||
@@ -306,7 +351,7 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
角色
|
||||
</div>
|
||||
<Badge>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
|
||||
<Badge>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
|
||||
<span>账号状态</span>
|
||||
@@ -424,7 +469,18 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<StatsSummary stats={stats} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LimitBadge({ label, value, unit }: { label: string; value?: number; unit: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums tracking-tight">
|
||||
{value !== undefined && value > 0 ? value : "不限"}
|
||||
</div>
|
||||
{value !== undefined && value > 0 && <div className="text-xs text-muted-foreground">{unit}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -718,7 +774,7 @@ function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }
|
||||
)
|
||||
}
|
||||
|
||||
function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
|
||||
function CleanupSection({ mailbox, stats, showStats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; showStats: boolean; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
function confirmCleanup(target: "empty-trash" | "empty-spam" | "archive-read-inbox", title: string, destructive = false) {
|
||||
setPendingConfirm({
|
||||
@@ -731,7 +787,7 @@ function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mail
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatsSummary stats={stats} />
|
||||
{showStats && <StatsSummary stats={stats} />}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>清理当前邮箱</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
|
||||
@@ -23,6 +23,14 @@ elif id rspamd >/dev/null 2>&1; then
|
||||
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
|
||||
fi
|
||||
|
||||
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
|
||||
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
|
||||
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
|
||||
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
|
||||
fi
|
||||
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
|
||||
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
|
||||
|
||||
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
|
||||
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
@@ -45,6 +53,7 @@ postconf -e "smtpd_milters = inet:127.0.0.1:11332"
|
||||
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
|
||||
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
|
||||
|
||||
# Rspamd DKIM keys are exported after API seed/migrations create the SQLite DB.
|
||||
/usr/local/bin/lanqin-api >/tmp/lanqin-api-bootstrap.log 2>&1 &
|
||||
|
||||
@@ -2,4 +2,4 @@ driver = sqlite
|
||||
connect = /data/lanqin.db
|
||||
default_pass_scheme = BLF-CRYPT
|
||||
password_query = SELECT address AS user, password_hash AS password FROM mailboxes WHERE address = '%u' AND status = 'active'
|
||||
user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active')
|
||||
user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=' || CAST(m.quota_mb AS TEXT) || 'M' AS quota_rule FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=1024M' AS quota_rule WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active')
|
||||
|
||||
@@ -10,21 +10,42 @@ ssl_cert = </etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
ssl_key = </etc/ssl/private/ssl-cert-snakeoil.key
|
||||
|
||||
mail_home = /var/mail/vhosts/%d/%n
|
||||
mail_max_userip_connections = 10
|
||||
recipient_delimiter = +
|
||||
|
||||
plugin {
|
||||
quota = maildir:User quota
|
||||
quota_rule = *:storage=1G
|
||||
# auth_policy_server is configured via dovecot-sql.conf.ext user_query
|
||||
}
|
||||
|
||||
auth_policy_server_url = http://127.0.0.1:8080/auth-policy
|
||||
auth_policy_server_api_header = Content-Type: application/json
|
||||
auth_policy_hash_mech = sha256
|
||||
auth_policy_hash_truncate = 12
|
||||
auth_policy_hash_nonce = __LANQIN_AUTH_POLICY_HASH_NONCE__
|
||||
auth_policy_request_attributes = protocol=imap username=user
|
||||
|
||||
namespace inbox {
|
||||
inbox = yes
|
||||
mailbox Drafts {
|
||||
auto = create
|
||||
special_use = \Drafts
|
||||
}
|
||||
mailbox Sent {
|
||||
auto = create
|
||||
special_use = \Sent
|
||||
}
|
||||
mailbox Trash {
|
||||
auto = create
|
||||
special_use = \Trash
|
||||
}
|
||||
mailbox Archive {
|
||||
auto = create
|
||||
special_use = \Archive
|
||||
}
|
||||
mailbox Spam {
|
||||
auto = create
|
||||
special_use = \Junk
|
||||
}
|
||||
}
|
||||
@@ -38,6 +59,14 @@ userdb {
|
||||
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||
}
|
||||
|
||||
protocol imap {
|
||||
mail_plugins = quota imap_quota
|
||||
}
|
||||
|
||||
protocol pop3 {
|
||||
mail_plugins = quota
|
||||
}
|
||||
|
||||
service imap-login {
|
||||
inet_listener imaps {
|
||||
port = 993
|
||||
|
||||
@@ -4,8 +4,15 @@ set -eu
|
||||
: "${LANQIN_TLS_KEY_FILE:=}"
|
||||
addgroup --system --gid 5000 vmail 2>/dev/null || true
|
||||
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
|
||||
mkdir -p /var/mail/vhosts
|
||||
mkdir -p /data /var/mail/vhosts
|
||||
chown -R 5000:5000 /var/mail/vhosts
|
||||
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
|
||||
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
|
||||
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
|
||||
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
|
||||
fi
|
||||
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
|
||||
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
|
||||
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
|
||||
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
@@ -18,4 +25,5 @@ if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
fi
|
||||
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
|
||||
exec dovecot -F
|
||||
|
||||
Reference in New Issue
Block a user