feat: align NewSzxcn admin experience

This commit is contained in:
zxyszx
2026-08-02 15:07:16 +08:00
parent 7c6b0838b5
commit 9b59629993
23 changed files with 583 additions and 828 deletions
+26 -221
View File
@@ -1,244 +1,49 @@
# LanQin Email # NewSzxcn-Email
[![English](https://img.shields.io/badge/English-README-blue)](./README.en.md) NewSzxcn-Email is a self-hosted, manageable, ready-to-run open-source email system.
[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.md)
![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go) Live site: [mail.newszxcn.com](https://mail.newszxcn.com)
![React](https://img.shields.io/badge/React-18.3-61DAFB?logo=react)
![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript)
![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker)
![Postfix](https://img.shields.io/badge/Postfix-5E3C2B?logo=maildotru)
![Dovecot](https://img.shields.io/badge/Dovecot-4B8BBE)
![Rspamd](https://img.shields.io/badge/Rspamd-FFD045)
LanQin Email is a self-hosted full-stack webmail solution. The frontend is built with React + TypeScript + shadcn/ui, the backend uses Go + SQLite, and deployment can run as a single all-in-one container with API, Web, Nginx, Postfix, Dovecot, and Rspamd integrated.
Community: [Telegram group](https://t.me/+EhII7MSyi3QwNDQ5)
## Features ## Features
- **Webmail client**: multiple mailbox switching, folders, reading and composing messages, drafts, scheduled sending, attachments, search, labels, stars, move/delete, read/unread status. - Webmail: inbox, compose, attachments, drafts, search, stars, labels, read/unread
- **Mailbox enhancements**: contacts, signatures, inbox rules, sender blacklist, mail statistics, archive read messages, empty Trash/Spam. - Multi-mailbox and multi-domain management, DKIM, DNS checks, forwarding
- **Multi-domain / multi-mailbox**: domain management, DKIM key generation, DNS record display and checks, mailbox accounts, alias forwarding, catch-all toggle. - Account management, mailbox quotas, permission quotas, registration, mailbox requests
- **Accounts and permissions**: login/registration, session management, TOTP two-factor authentication, Cloudflare Turnstile, user self-service mailbox requests, permission groups/RBAC. - Admin console, all mail, send queue, system settings
- **Admin panel**: overview checklist, user/permission group/domain/mailbox/alias/all-message management, system settings, mail templates, SMTP testing. - Postfix, Dovecot, Rspamd, SQLite, Docker single-container deployment
- **Mail service stack**: Postfix delivery, Dovecot IMAP/POP3, Rspamd anti-spam and DKIM signing, Maildir-to-SQLite sync.
- **Deployment friendly**: default all-in-one single container, plus a multi-container stack for debugging Postfix/Dovecot/Rspamd.
## UI Preview ## Screenshots
| Webmail reading and list | Compose · rich-text toolbar | Replace these images when needed:
| --- | --- |
| ![Webmail reading and list](./docs/screenshots/mail-preview.png) | ![Compose rich-text toolbar](./docs/screenshots/compose-preview.png) |
| Switch mailboxes, folders, search, labels, stars, and message reading panel. | Rich-text toolbar supports fonts, headings, bold, italic, underline, colors, highlights, lists, alignment, quotes, code blocks, attachments, emoji, and scheduled sending. |
| Admin panel · system overview | Third-party client configuration |
| ![Admin panel system overview](./docs/screenshots/admin-preview.png) | ![Third-party client configuration](./docs/screenshots/client-preview.png) |
| Manage users, permission groups, domains, mailboxes, aliases, system settings, and send audits. | View IMAP / POP3 / SMTP servers, ports, security modes, and account information in one place. |
## Repository Structure - `docs/screenshots/mail-preview.png`
- `docs/screenshots/compose-preview.png`
- `docs/screenshots/admin-preview.png`
- `docs/screenshots/client-preview.png`
```text ## Stack
.
├── apps/api # Go API, SQLite schema, mail sync, and business logic
├── apps/web # React/Vite Webmail and admin panel
├── deploy # Docker Compose, image build, Postfix/Dovecot/Rspamd config
└── .github/workflows # Docker image release workflows
```
## Requirements - Backend: Go
- Frontend: React + TypeScript + shadcn/ui
- Database: SQLite
- Mail stack: Postfix + Dovecot + Rspamd
- Deployment: Docker / Docker Compose
### Development ## Quick Deploy
- Go 1.25+
- Node.js 20+
- pnpm 10.28.2 (can be enabled through corepack)
### Deployment
- Docker Engine
- Docker Compose v2
- A resolvable mail domain, plus available ports such as 25 / 465 / 587 / 993 / 995
> Public email sending and receiving also requires correct MX, SPF, DKIM, and DMARC records, and you should confirm that your cloud provider does not block SMTP ports.
## Quick Start
### Local Development
Backend:
```bash
cd apps/api
go mod download
go test ./...
go run ./cmd/server
```
Frontend (new terminal):
```bash
cd apps/web
corepack enable
corepack prepare pnpm@10.28.2 --activate
pnpm install
pnpm run dev
```
Open:
- Web: `http://localhost:5173`
- API: `http://localhost:8080`
The default admin email is `admin@lanqin.local`. For development, explicitly set `LANQIN_ADMIN_PASSWORD`; if it is not set, the backend generates a random password on first startup and prints it to the logs.
### Docker Deployment (single container)
A server only needs the Compose files and configuration under `deploy/`; building from source is not required:
```bash ```bash
cd deploy cd deploy
cp .env.example .env cp .env.example .env
# Edit .env: domain, public URL, admin email, admin password, etc. # Edit domain, public URL, admin email, and admin password
docker compose pull docker compose up -d --build
docker compose up -d
``` ```
Common commands: Public mail delivery requires MX, SPF, DKIM, DMARC, and open mail ports.
```bash ## Note
# View logs
docker compose logs -f lanqin-email
# Pull the latest image and restart This is the NewSzxcn maintained version. Future changes are based on this repository.
docker compose pull
docker compose up -d
# Stop services
docker compose down
```
To build the image locally from the full source repository:
```bash
cd deploy
cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
See [`deploy/README.md`](./deploy/README.md) for more deployment details.
## First Deployment Checklist
1. Edit `deploy/.env`: at minimum, change `LANQIN_PUBLIC_HOSTNAME`, `LANQIN_PUBLIC_BASE_URL`, `LANQIN_ADMIN_EMAIL`, and `LANQIN_ADMIN_PASSWORD`.
2. In production, mount real TLS certificates and set `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`.
3. Log in to the admin panel and add your mail domain.
4. Copy and configure MX, SPF, DKIM, and DMARC records from domain management, then run the DNS check.
5. Create mailbox accounts, alias forwarding, or permission groups; enable registration, 2FA, Turnstile, and self-service mailbox requests as needed.
6. Use the admin SMTP test and Webmail send/receive tests to confirm the full path works.
## Key Environment Variables
See [`deploy/.env.example`](./deploy/.env.example) for the full configuration. Common variables:
| Variable | Description | Default / Example |
|------|------|-----------|
| `LANQIN_IMAGE` | All-in-one image | `ghcr.io/lanqin996/lanqin-email:latest` |
| `LANQIN_PUBLIC_HOSTNAME` | Mail server hostname; affects Postfix/DNS display/links | `mail.example.com` |
| `LANQIN_PUBLIC_BASE_URL` | Public Webmail URL | `https://mail.example.com` |
| `LANQIN_ADMIN_EMAIL` | Initial admin email | `admin@example.com` |
| `LANQIN_ADMIN_PASSWORD` | Initial admin password; must be changed in production | `ChangeMe123!` |
| `LANQIN_DB_PATH` | SQLite database path | `/data/lanqin.db` |
| `LANQIN_ALLOW_INSECURE_HTTP` | Allow non-HTTPS cookies; useful for local debugging | `false` |
| `LANQIN_OPEN_REGISTRATION` | Enable public registration | `false` |
| `LANQIN_TWO_FACTOR_ENABLED` | Global 2FA feature toggle | `false` |
| `LANQIN_TURNSTILE_ENABLED` | Enable Turnstile | `false` |
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail outbound SMTP | `127.0.0.1` / `25` |
| `LANQIN_MAILDIR_ROOT` | Maildir root directory | `/var/mail/vhosts` |
| `LANQIN_CATCH_ALL_ENABLED` | Whether unregistered recipient addresses go into all messages | `false` |
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | Allow users to request mailboxes by themselves | `false` |
| `LANQIN_EXTERNAL_IMAP_ENABLED` | Enable external IMAP access; also configurable in Admin > System Settings > External IMAP | `false` |
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | Encryption key for external IMAP passwords; required before enabling access; also configurable in admin | Random long string |
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | Sync interval for external IMAP local-storage mode; also configurable in admin | `300` |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | Allow external IMAP to connect to private/localhost hosts; also configurable in admin | `false` |
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail external IMAP OAuth2; callback is `/api/external-imap-oauth/gmail/callback` | Empty |
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook external IMAP OAuth2; callback is `/api/external-imap-oauth/outlook/callback` | Empty |
## Architecture
```text
┌────────────────────────────────────────────────────────────┐
│ lanqin-email single container │
│ │
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Nginx │ ───▶ │ Go API │ ───▶ │ SQLite /data │ │
│ │ Web │ │ Webmail API│ └──────┬───────┘ │
│ │ static │ └─────┬──────┘ │ │
│ └─────────┘ │ Maildir sync │ maps │
│ ┌─────────┐ ┌─────▼──────┐ ┌──────▼───────┐ │
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │ │
│ │ DKIM/AS │ │ SMTP/MTA │ │ IMAP/POP3 │ │
│ └─────────┘ └────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
```
Mail flow:
1. **Receiving**: Postfix receives mail → Rspamd scores/marks it → Dovecot writes to Maildir → API worker syncs it into SQLite → Webmail displays it.
2. **Sending**: Webmail calls the API → API builds MIME → SMTP submits to Postfix or an external SMTP server → mail is delivered to the destination.
3. **Local delivery**: In development, internal mailboxes can send directly into the recipient Inbox; if `LANQIN_SMTP_HOST` is not configured, external recipients are not actually delivered.
4. **Third-party clients**: Connect with SMTP 465/587, IMAP 993, or POP3 995; in production, configure certificates that match `LANQIN_PUBLIC_HOSTNAME`.
5. **External mailbox access**: Users can add external IMAP accounts in personal mailbox management. Local-storage mode syncs mail into the database; remote-direct mode reads from the remote server each time and does not write into local mail tables.
## Open API
External integrations should use the versioned `/api/open/v1` endpoints with scoped API Tokens. See the [API guide](docs/API.md) and the machine-readable [OpenAPI 3.1 contract](docs/openapi.json). Sending supports idempotency keys; final delivery events can be ingested through a signed endpoint and all status changes can be pushed through the reliable signed webhook outbox.
## Development and Verification
```bash
# API tests
cd apps/api
go test ./...
# Web checks and build
cd apps/web
pnpm run check
# Single-container source build verification
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
## Production Notes
- In production, always change the default admin password and protect `.env`, the SQLite database, Maildir, and DKIM private keys.
- The Web UI can sit behind host Nginx / aaPanel / an edge gateway, but SMTP/IMAP/POP3 certificates must be mounted separately for Postfix/Dovecot inside the container.
- Cloud providers often block port 25 by default; if public email does not send or receive, first check ports, security groups, firewalls, and reverse DNS.
- SQLite is suitable for single-node deployments; before multi-node deployment, migrate the database and adjust Postfix/Dovecot query configuration accordingly.
## SMTP Submission
- Third-party client SMTP submission on `465/587` is handled by the LanQin API process.
- Before enabling SMTP submission, configure `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`; the API will not expose 465/587 externally with a localhost self-signed certificate.
- Postfix only keeps port `25` for public inbound mail and internal/external relay.
- Webmail/API and third-party client sends are first written into Sent, then enter the send queue.
- The send queue is relayed by a LanQin API background worker to `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`; failures are audited and retried with backoff.
- v1 supports sending from the user's own mailbox. For send-as, use an enabled alias forwarding source that points to the user's mailbox, or configure `send_as_grants` in the database.
- If the client later writes its own Sent copy through IMAP APPEND, Maildir sync deduplicates by `Message-ID` within the Sent folder.
## License ## License
[MIT](./LICENSE) [MIT](./LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
</picture>
</a>
Friends: [LINUX DO](https://linux.do/) — a new ideal community
+37 -214
View File
@@ -1,245 +1,68 @@
# LanQin Email # NewSzxcn-Email
[![English](https://img.shields.io/badge/English-README-blue)](./README.en.md) NewSzxcn-Email 是一个可自建、可管理、开箱即用的开源邮箱系统。
[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.md)
![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go) 在线地址:[mail.newszxcn.com](https://mail.newszxcn.com)
![React](https://img.shields.io/badge/React-18.3-61DAFB?logo=react)
![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript)
![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker)
![Postfix](https://img.shields.io/badge/Postfix-5E3C2B?logo=maildotru)
![Dovecot](https://img.shields.io/badge/Dovecot-4B8BBE)
![Rspamd](https://img.shields.io/badge/Rspamd-FFD045)
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。 ## 功能
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5) - Webmail 收发邮件、写信、附件、草稿、搜索、星标、标签、已读/未读
- 多邮箱、多域名、DKIM、DNS 检测、邮件转发
- 账号管理、邮箱数量配额、权限配额、注册与自助申请邮箱
- 管理后台、全部邮件、发送队列、系统设置
- Postfix、Dovecot、Rspamd、SQLite、Docker 单容器部署
## 功能特性 ## 截图
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。 截图可自行替换上传:
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
- **邮件服务栈**Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
## 界面预览 - `docs/screenshots/mail-preview.png`
- `docs/screenshots/compose-preview.png`
- `docs/screenshots/admin-preview.png`
- `docs/screenshots/client-preview.png`
| Webmail 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 | ## 技术栈
| --- | --- |
| ![Webmail 邮件阅读与列表](./docs/screenshots/mail-preview.png) | ![写邮件富文本编辑工具栏](./docs/screenshots/compose-preview.png) |
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
| 管理后台 · 系统概览 | 第三方客户端配置 |
| ![管理后台系统概览](./docs/screenshots/admin-preview.png) | ![第三方客户端配置](./docs/screenshots/client-preview.png) |
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
## 目录结构 - 后端:Go
- 前端:React + TypeScript + shadcn/ui
- 数据库:SQLite
- 邮件服务:Postfix + Dovecot + Rspamd
- 部署:Docker / Docker Compose
```text ## 快速部署
.
├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑 ```bash
├── apps/web # React/Vite Webmail 与管理后台 cd deploy
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置 cp .env.example .env
└── .github/workflows # Docker 镜像发布流水线 # 修改域名、访问地址、管理员邮箱、管理员密码
docker compose up -d --build
``` ```
## 环境要求 公网收发邮件需要配置:
### 开发环境 - MX
- SPF
- DKIM
- DMARC
- 25 / 465 / 587 / 993 / 995 端口
- 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 ```bash
cd apps/api cd apps/api
go mod download
go test ./...
go run ./cmd/server go run ./cmd/server
``` ```
前端(新终端):
```bash ```bash
cd apps/web cd apps/web
corepack enable
corepack prepare pnpm@10.28.2 --activate
pnpm install pnpm install
pnpm run dev pnpm run dev
``` ```
访问: ## 说明
- Web`http://localhost:5173` 这是 NewSzxcn 自用维护版本,后续功能和界面修改都以本仓库为准。
- API`http://localhost:8080`
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
### Docker 部署(单容器)
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
```bash
cd deploy
cp .env.example .env
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码等
docker compose pull
docker compose up -d
```
常用命令:
```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` |
| `LANQIN_EXTERNAL_IMAP_ENABLED` | 是否启用外部 IMAP 接入;也可在后台“系统设置 > 外部 IMAP”配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置;也可在后台配置 | 随机长字符串 |
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔;也可在后台配置 | `300` |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机;也可在后台配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 |
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
## 架构
```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 接收邮件 → 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` 的证书。
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
## 开放 API
外部系统应使用版本化的 `/api/open/v1` 接口和带 scope 的 API Token。详细说明见 [API 文档](docs/API.md),机器可读契约见 [OpenAPI 3.1](docs/openapi.json)。发信支持幂等键;最终投递事件可通过签名入口写入,全部状态变化也可通过可靠的签名 webhook outbox 主动推送。
## 开发与验证
```bash
# API 测试
cd apps/api
go test ./...
# Web 检查与构建
cd apps/web
pnpm run check
# 单容器源码构建验证
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
## 生产注意事项
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`、SQLite 数据库、Maildir 与 DKIM 私钥。
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
## SMTP 提交
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`API 不会用 localhost 自签证书对外提供 465/587。
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
- v1 支持本人邮箱发信;如需 send-as,可使用启用的别名转发 source 指向本人邮箱,或在数据库中配置 `send_as_grants`
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
## License ## License
[MIT](./LICENSE) [MIT](./LICENSE)
## Star 趋势
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
</picture>
</a>
友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
+37 -214
View File
@@ -1,245 +1,68 @@
# LanQin Email # NewSzxcn-Email
[![English](https://img.shields.io/badge/English-README-blue)](./README.en.md) NewSzxcn-Email 是一个可自建、可管理、开箱即用的开源邮箱系统。
[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.md)
![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go) 在线地址:[mail.newszxcn.com](https://mail.newszxcn.com)
![React](https://img.shields.io/badge/React-18.3-61DAFB?logo=react)
![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript)
![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker)
![Postfix](https://img.shields.io/badge/Postfix-5E3C2B?logo=maildotru)
![Dovecot](https://img.shields.io/badge/Dovecot-4B8BBE)
![Rspamd](https://img.shields.io/badge/Rspamd-FFD045)
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。 ## 功能
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5) - Webmail 收发邮件、写信、附件、草稿、搜索、星标、标签、已读/未读
- 多邮箱、多域名、DKIM、DNS 检测、邮件转发
- 账号管理、邮箱数量配额、权限配额、注册与自助申请邮箱
- 管理后台、全部邮件、发送队列、系统设置
- Postfix、Dovecot、Rspamd、SQLite、Docker 单容器部署
## 功能特性 ## 截图
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。 截图可自行替换上传:
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
- **邮件服务栈**Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
## 界面预览 - `docs/screenshots/mail-preview.png`
- `docs/screenshots/compose-preview.png`
- `docs/screenshots/admin-preview.png`
- `docs/screenshots/client-preview.png`
| Webmail 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 | ## 技术栈
| --- | --- |
| ![Webmail 邮件阅读与列表](./docs/screenshots/mail-preview.png) | ![写邮件富文本编辑工具栏](./docs/screenshots/compose-preview.png) |
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
| 管理后台 · 系统概览 | 第三方客户端配置 |
| ![管理后台系统概览](./docs/screenshots/admin-preview.png) | ![第三方客户端配置](./docs/screenshots/client-preview.png) |
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
## 目录结构 - 后端:Go
- 前端:React + TypeScript + shadcn/ui
- 数据库:SQLite
- 邮件服务:Postfix + Dovecot + Rspamd
- 部署:Docker / Docker Compose
```text ## 快速部署
.
├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑 ```bash
├── apps/web # React/Vite Webmail 与管理后台 cd deploy
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置 cp .env.example .env
└── .github/workflows # Docker 镜像发布流水线 # 修改域名、访问地址、管理员邮箱、管理员密码
docker compose up -d --build
``` ```
## 环境要求 公网收发邮件需要配置:
### 开发环境 - MX
- SPF
- DKIM
- DMARC
- 25 / 465 / 587 / 993 / 995 端口
- 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 ```bash
cd apps/api cd apps/api
go mod download
go test ./...
go run ./cmd/server go run ./cmd/server
``` ```
前端(新终端):
```bash ```bash
cd apps/web cd apps/web
corepack enable
corepack prepare pnpm@10.28.2 --activate
pnpm install pnpm install
pnpm run dev pnpm run dev
``` ```
访问: ## 说明
- Web`http://localhost:5173` 这是 NewSzxcn 自用维护版本,后续功能和界面修改都以本仓库为准。
- API`http://localhost:8080`
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
### Docker 部署(单容器)
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
```bash
cd deploy
cp .env.example .env
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码等
docker compose pull
docker compose up -d
```
常用命令:
```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` |
| `LANQIN_EXTERNAL_IMAP_ENABLED` | 是否启用外部 IMAP 接入;也可在后台“系统设置 > 外部 IMAP”配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置;也可在后台配置 | 随机长字符串 |
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔;也可在后台配置 | `300` |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机;也可在后台配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 |
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
## 架构
```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 接收邮件 → 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` 的证书。
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
## 开放 API
外部系统应使用版本化的 `/api/open/v1` 接口和带 scope 的 API Token。详细说明见 [API 文档](docs/API.md),机器可读契约见 [OpenAPI 3.1](docs/openapi.json)。发信支持幂等键;最终投递事件可通过签名入口写入,全部状态变化也可通过可靠的签名 webhook outbox 主动推送。
## 开发与验证
```bash
# API 测试
cd apps/api
go test ./...
# Web 检查与构建
cd apps/web
pnpm run check
# 单容器源码构建验证
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
## 生产注意事项
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`、SQLite 数据库、Maildir 与 DKIM 私钥。
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
## SMTP 提交
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`API 不会用 localhost 自签证书对外提供 465/587。
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
- v1 支持本人邮箱发信;如需 send-as,可使用启用的别名转发 source 指向本人邮箱,或在数据库中配置 `send_as_grants`
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
## License ## License
[MIT](./LICENSE) [MIT](./LICENSE)
## Star 趋势
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
</picture>
</a>
友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
+35 -10
View File
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
ORDER BY u.created_at DESC`) ORDER BY u.created_at DESC`)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users") respondError(w, http.StatusInternalServerError, "failed to list users")
@@ -62,13 +62,15 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
for rows.Next() { for rows.Next() {
var item AdminUser var item AdminUser
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string var created, mailboxCSV string
if err := rows.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil { if err := rows.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan users") respondError(w, http.StatusInternalServerError, "failed to scan users")
return return
} }
item.Disabled = intBool(disabled) item.Disabled = intBool(disabled)
item.TwoFactorEnabled = intBool(twoFactorEnabled) item.TwoFactorEnabled = intBool(twoFactorEnabled)
item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
item.CreatedAt = parseTime(created) item.CreatedAt = parseTime(created)
item.Mailboxes = splitCSV(mailboxCSV) item.Mailboxes = splitCSV(mailboxCSV)
items = append(items, item) items = append(items, item)
@@ -97,6 +99,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"` Role string `json:"role"`
Password string `json:"password"` Password string `json:"password"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs []string `json:"permissionGroupIds"` PermissionGroupIDs []string `json:"permissionGroupIds"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
@@ -125,6 +128,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusForbidden, "only administrators can create administrator users") respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return return
} }
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
if err != nil {
badRequest(w, err)
return
}
if role == "admin" {
mailboxLimitOverride = nil
}
if len(req.Password) < 8 { if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters")) badRequest(w, errors.New("password must be at least 8 characters"))
return return
@@ -142,8 +153,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now); err != nil { VALUES(?,?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
badRequest(w, err) badRequest(w, err)
return return
} }
@@ -174,6 +185,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` Role string `json:"role"`
Disabled *bool `json:"disabled"` Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"` PermissionGroupIDs *[]string `json:"permissionGroupIds"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
@@ -210,6 +222,17 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("default administrator must remain an active super administrator")) badRequest(w, errors.New("default administrator must remain an active super administrator"))
return return
} }
mailboxLimitOverride := existing.MailboxLimitOverride
if req.MailboxLimitOverride != nil {
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
if err != nil {
badRequest(w, err)
return
}
}
if role == "admin" {
mailboxLimitOverride = nil
}
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil { if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
badRequest(w, err) badRequest(w, err)
return return
@@ -254,8 +277,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`, if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update user") respondError(w, http.StatusInternalServerError, "failed to update user")
return return
} }
@@ -1055,18 +1078,20 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
} }
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) { func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') row := a.db.QueryRowContext(ctx, `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
WHERE u.id=? WHERE u.id=?
GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at`, id) GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
var item AdminUser var item AdminUser
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string var created, mailboxCSV string
if err := row.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil { if err := row.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
return nil, err return nil, err
} }
item.Disabled = intBool(disabled) item.Disabled = intBool(disabled)
item.TwoFactorEnabled = intBool(twoFactorEnabled) item.TwoFactorEnabled = intBool(twoFactorEnabled)
item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
item.CreatedAt = parseTime(created) item.CreatedAt = parseTime(created)
item.Mailboxes = splitCSV(mailboxCSV) item.Mailboxes = splitCSV(mailboxCSV)
if err := a.attachUserAuthorization(ctx, &item.User); err != nil { if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
+39 -5
View File
@@ -129,6 +129,7 @@ func (a *App) migrate(ctx context.Context) error {
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
two_factor_secret TEXT NOT NULL DEFAULT '', two_factor_secret TEXT NOT NULL DEFAULT '',
two_factor_enabled INTEGER NOT NULL DEFAULT 0, two_factor_enabled INTEGER NOT NULL DEFAULT 0,
mailbox_limit_override INTEGER,
disabled INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
@@ -138,7 +139,7 @@ func (a *App) migrate(ctx context.Context) error {
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
permissions_json 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}', limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"maxMailboxCount":9,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
system INTEGER NOT NULL DEFAULT 0, system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
@@ -603,6 +604,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateUsersForTwoFactor(ctx); err != nil { if err := a.migrateUsersForTwoFactor(ctx); err != nil {
return err return err
} }
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
return err
}
if err := a.migrateMailRulesBuilder(ctx); err != nil { if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err return err
} }
@@ -852,7 +856,7 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
if hasLimits { if hasLimits {
return nil 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}'`) _, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"maxMailboxCount":9,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
return err return err
} }
@@ -1050,6 +1054,36 @@ func (a *App) migrateUsersForTwoFactor(ctx context.Context) error {
return nil return nil
} }
func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
if err != nil {
return err
}
defer rows.Close()
hasColumn := 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, &notnull, &dflt, &pk); err != nil {
return err
}
if name == "mailbox_limit_override" {
hasColumn = true
}
}
if err := rows.Err(); err != nil {
return err
}
if hasColumn {
return nil
}
_, err = a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN mailbox_limit_override INTEGER`)
return err
}
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error { func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`) rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil { if err != nil {
@@ -1228,7 +1262,7 @@ func (a *App) seed(ctx context.Context) error {
return errors.New("invalid admin email") return errors.New("invalid admin email")
} }
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, adminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now); err != nil { VALUES(?,?,?,?,?,?,?,?)`, userID, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
return err return err
} }
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail) a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1389,7 +1423,7 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
return err return err
} }
now := a.now().UTC() now := a.now().UTC()
subject := "欢迎使用 LanQin Email" subject := "欢迎使用 NewSzxcn 邮箱"
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。" bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>" bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil { if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
@@ -1409,7 +1443,7 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")), MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
Subject: subject, Subject: subject,
From: "system@lanqin.local", From: "system@lanqin.local",
FromName: "LanQin Email", FromName: "NewSzxcn 邮箱",
To: []string{a.cfg.AdminEmail}, To: []string{a.cfg.AdminEmail},
SentAt: now, SentAt: now,
ReceivedAt: now, ReceivedAt: now,
+29 -4
View File
@@ -1147,7 +1147,7 @@ func TestPermissionGroupMailLimits(t *testing.T) {
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { 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) 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}) updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
domainID := mustDefaultDomainID(t, a) domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil) sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil)
@@ -1163,7 +1163,7 @@ func TestPermissionGroupMailLimits(t *testing.T) {
if code := user.do("GET", "/api/me", nil, &me); code != http.StatusOK { if code := user.do("GET", "/api/me", nil, &me); code != http.StatusOK {
t.Fatalf("me code=%d user=%+v", code, me.User) 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 { if me.User.Limits.MaxAttachmentMB != 1 || me.User.Limits.MaxMailboxCount != 9 || 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) t.Fatalf("user limits not attached: %+v", me.User.Limits)
} }
@@ -1351,6 +1351,31 @@ func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "alice"}, &errBody); code != http.StatusConflict { if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "alice"}, &errBody); code != http.StatusConflict {
t.Fatalf("duplicate apply code=%d body=%v", code, errBody) t.Fatalf("duplicate apply code=%d body=%v", code, errBody)
} }
limits := defaultPermissionLimits()
limits.MaxMailboxCount = 1
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), limits)
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "bob", "displayName": "Bob"}, &errBody); code != http.StatusForbidden {
t.Fatalf("mailbox count limit code=%d body=%v", code, errBody)
}
var updated AdminUser
if code := admin.do("POST", "/api/admin/users/"+created.ID, map[string]any{
"displayName": created.DisplayName,
"role": "user",
"disabled": false,
"mailboxLimitOverride": 2,
"permissionGroupIds": []string{},
}, &updated); code != http.StatusOK {
t.Fatalf("update user mailbox limit override code=%d user=%+v", code, updated)
}
if updated.MailboxLimitOverride == nil || *updated.MailboxLimitOverride != 2 || updated.Limits.MaxMailboxCount != 2 {
t.Fatalf("user mailbox limit override not attached: %+v", updated.User)
}
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "bob", "displayName": "Bob"}, &mailbox); code != http.StatusCreated || mailbox.Address != "bob@a.com" {
t.Fatalf("per-user mailbox limit apply code=%d mailbox=%+v", code, mailbox)
}
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "carol", "displayName": "Carol"}, &errBody); code != http.StatusForbidden {
t.Fatalf("per-user mailbox limit code=%d body=%v", code, errBody)
}
} }
func TestUserCanSelectMultipleMailboxes(t *testing.T) { func TestUserCanSelectMultipleMailboxes(t *testing.T) {
@@ -3956,11 +3981,11 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
"name": "Mailbox Viewers", "name": "Mailbox Viewers",
"description": "Can view mailboxes only", "description": "Can view mailboxes only",
"permissions": []string{PermissionAdminOverview, PermissionMailboxesView}, "permissions": []string{PermissionAdminOverview, PermissionMailboxesView},
"limits": PermissionLimits{MaxAttachmentMB: 5, SMTPDailyLimit: 8, SMTPMinuteLimit: 2, IMAPMinuteLimit: 5, POP3MinuteLimit: 3}, "limits": PermissionLimits{MaxAttachmentMB: 5, MaxMailboxCount: 4, SMTPDailyLimit: 8, SMTPMinuteLimit: 2, IMAPMinuteLimit: 5, POP3MinuteLimit: 3},
}, &customGroup); code != http.StatusCreated { }, &customGroup); code != http.StatusCreated {
t.Fatalf("custom permission group creation code=%d group=%+v", code, customGroup) 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 { if customGroup.Limits.MaxAttachmentMB != 5 || customGroup.Limits.MaxMailboxCount != 4 || 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) 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) { if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) {
+43 -21
View File
@@ -138,6 +138,7 @@ type PermissionGroup struct {
type PermissionLimits struct { type PermissionLimits struct {
MaxAttachmentMB int `json:"maxAttachmentMb"` MaxAttachmentMB int `json:"maxAttachmentMb"`
MaxMailboxCount int `json:"maxMailboxCount"`
SMTPDailyLimit int `json:"smtpDailyLimit"` SMTPDailyLimit int `json:"smtpDailyLimit"`
SMTPMinuteLimit int `json:"smtpMinuteLimit"` SMTPMinuteLimit int `json:"smtpMinuteLimit"`
IMAPMinuteLimit int `json:"imapMinuteLimit"` IMAPMinuteLimit int `json:"imapMinuteLimit"`
@@ -147,6 +148,7 @@ type PermissionLimits struct {
func defaultPermissionLimits() PermissionLimits { func defaultPermissionLimits() PermissionLimits {
return PermissionLimits{ return PermissionLimits{
MaxAttachmentMB: 25, MaxAttachmentMB: 25,
MaxMailboxCount: 9,
SMTPDailyLimit: 200, SMTPDailyLimit: 200,
SMTPMinuteLimit: 20, SMTPMinuteLimit: 20,
IMAPMinuteLimit: 200, IMAPMinuteLimit: 200,
@@ -158,6 +160,9 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
if limits.MaxAttachmentMB < 0 { if limits.MaxAttachmentMB < 0 {
return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative") return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative")
} }
if limits.MaxMailboxCount < 0 {
return PermissionLimits{}, errors.New("maxMailboxCount cannot be negative")
}
if limits.SMTPDailyLimit < 0 { if limits.SMTPDailyLimit < 0 {
return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative") return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative")
} }
@@ -173,6 +178,17 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
return limits, nil return limits, nil
} }
func normalizeMailboxLimitOverride(value *int) (*int, error) {
if value == nil {
return nil, nil
}
if *value < 0 {
return nil, errors.New("mailboxLimitOverride cannot be negative")
}
normalized := *value
return &normalized, nil
}
func decodeStoredLimits(value string) PermissionLimits { func decodeStoredLimits(value string) PermissionLimits {
limits := defaultPermissionLimits() limits := defaultPermissionLimits()
if strings.TrimSpace(value) == "" { if strings.TrimSpace(value) == "" {
@@ -198,6 +214,7 @@ func encodePermissionLimits(limits PermissionLimits) string {
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits { func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
return PermissionLimits{ return PermissionLimits{
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB), MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount),
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit), SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit), SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit), IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
@@ -221,6 +238,7 @@ func minimalLimits() PermissionLimits {
// when no group has a limit set for a given field. // when no group has a limit set for a given field.
return PermissionLimits{ return PermissionLimits{
MaxAttachmentMB: 1, MaxAttachmentMB: 1,
MaxMailboxCount: 1,
SMTPDailyLimit: 1, SMTPDailyLimit: 1,
SMTPMinuteLimit: 1, SMTPMinuteLimit: 1,
IMAPMinuteLimit: 1, IMAPMinuteLimit: 1,
@@ -236,6 +254,7 @@ func actorCanGrantLimits(actor *User, limits PermissionLimits) bool {
return true return true
} }
return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) && return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) &&
canGrantLimitValue(actor.Limits.MaxMailboxCount, limits.MaxMailboxCount) &&
canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) && canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) &&
canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) && canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) &&
canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) && canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) &&
@@ -292,20 +311,20 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"}, {Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"},
{Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"}, {Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"},
{Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"}, {Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"},
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱账号。", Category: "个人中心"}, {Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"},
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"}, {Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"}, {Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建用户", Description: "创建普通用户并分配权限。", Category: "用户"}, {Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑用户", Description: "修改用户显示名称、状态和权限。", Category: "用户"}, {Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"},
{Key: PermissionUsersDelete, Label: "删除用户", Description: "删除非受保护用户。", Category: "用户"}, {Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
{Key: PermissionUsersResetPassword, Label: "重置用户密码", Description: "为用户重置登录密码。", Category: "用户"}, {Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
{Key: PermissionGroupsView, Label: "查看权限", Description: "查看权限、权限目录和使用人数。", Category: "权限"}, {Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"},
{Key: PermissionGroupsCreate, Label: "创建权限", Description: "创建自定义权限。", Category: "权限"}, {Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"},
{Key: PermissionGroupsUpdate, Label: "编辑权限", Description: "修改自定义权限名称、说明和权限。", Category: "权限"}, {Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"},
{Key: PermissionGroupsDelete, Label: "删除权限", Description: "删除未被用户使用的自定义权限。", Category: "权限"}, {Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"},
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"}, {Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"}, {Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
@@ -315,15 +334,15 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"}, {Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"},
{Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"}, {Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"},
{Key: PermissionMailboxesView, Label: "查看邮箱账号", Description: "查看邮箱账号列表和归属用户。", Category: "邮箱账号"}, {Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"},
{Key: PermissionMailboxesCreate, Label: "创建邮箱账号", Description: "创建邮箱账号并准备归属用户。", Category: "邮箱账号"}, {Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"},
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱账号", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱账号"}, {Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"},
{Key: PermissionMailboxesDelete, Label: "删除邮箱账号", Description: "删除邮箱账号及关联邮件文件。", Category: "邮箱账号"}, {Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"},
{Key: PermissionAliasesView, Label: "查看别名转发", Description: "查看别名转发规则。", Category: "别名转发"}, {Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesCreate, Label: "创建别名转发", Description: "创建新的别名转发。", Category: "别名转发"}, {Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesUpdate, Label: "编辑别名转发", Description: "修改别名转发来源、目标和启用状态。", Category: "别名转发"}, {Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"},
{Key: PermissionAliasesDelete, Label: "删除别名转发", Description: "删除别名转发规则。", Category: "别名转发"}, {Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"},
{Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"}, {Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"},
{Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"}, {Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"},
@@ -435,8 +454,8 @@ func defaultPermissionGroups() []PermissionGroup {
return []PermissionGroup{ return []PermissionGroup{
{ {
ID: PermissionGroupSuperAdmin, ID: PermissionGroupSuperAdmin,
Name: "超级管理员", Name: "管理员",
Description: "拥有全部后台权限,由用户身份决定,不通过权限分配。", Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。",
Permissions: allPermissionKeys(), Permissions: allPermissionKeys(),
Limits: PermissionLimits{}, Limits: PermissionLimits{},
System: true, System: true,
@@ -611,6 +630,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
} }
u.Permissions = permissions u.Permissions = permissions
u.Limits = limits u.Limits = limits
if u.Role != "admin" && u.MailboxLimitOverride != nil {
u.Limits.MaxMailboxCount = *u.MailboxLimitOverride
}
u.PermissionGroupIDs = groupIDs u.PermissionGroupIDs = groupIDs
u.PermissionGroups = groups u.PermissionGroups = groups
u.Protected = a.isDefaultAdminUser(u) u.Protected = a.isDefaultAdminUser(u)
@@ -769,7 +791,7 @@ func (a *App) effectiveLimitsForUserGroups(ctx context.Context, tx *sql.Tx, grou
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) { func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
if role == "admin" { if role == "admin" {
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"} group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "管理员"}
return []string{group.ID}, []PermissionGroupSummary{group}, nil return []string{group.ID}, []PermissionGroupSummary{group}, nil
} }
ids := []string{PermissionGroupRegular} ids := []string{PermissionGroupRegular}
@@ -88,6 +88,17 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusConflict, "该邮箱地址已被占用") respondError(w, http.StatusConflict, "该邮箱地址已被占用")
return return
} }
if user.Role != "admin" && user.Limits.MaxMailboxCount > 0 {
var ownedCount int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=? AND status='active'`, user.ID).Scan(&ownedCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check mailbox quota")
return
}
if ownedCount >= user.Limits.MaxMailboxCount {
respondError(w, http.StatusForbidden, "邮箱数量已达上限")
return
}
}
var passwordHash string var passwordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND disabled=0`, user.ID).Scan(&passwordHash); err != nil { if err := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND disabled=0`, user.ID).Scan(&passwordHash); err != nil {
+16 -8
View File
@@ -270,17 +270,19 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
return nil, errors.New("no session") return nil, errors.New("no session")
} }
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
FROM sessions s JOIN users u ON u.id=s.user_id FROM sessions s JOIN users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano)) WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
var u User var u User
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created string var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil { if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
return nil, err return nil, err
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled) u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
if u.Disabled { if u.Disabled {
return nil, errors.New("disabled") return nil, errors.New("disabled")
@@ -297,18 +299,20 @@ func (a *App) authenticateAPIToken(r *http.Request) (*User, map[string]bool, err
return nil, nil, errors.New("no api token") return nil, nil, errors.New("no api token")
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
row := a.db.QueryRowContext(r.Context(), `SELECT at.id,at.scopes_json,u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at row := a.db.QueryRowContext(r.Context(), `SELECT at.id,at.scopes_json,u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
FROM api_tokens at JOIN users u ON u.id=at.user_id FROM api_tokens at JOIN users u ON u.id=at.user_id
WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now) WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now)
var tokenID, scopesJSON string var tokenID, scopesJSON string
var u User var u User
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created string var created string
if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil { if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
return nil, nil, err return nil, nil, err
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled) u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
if u.Disabled { if u.Disabled {
return nil, nil, errors.New("disabled") return nil, nil, errors.New("disabled")
@@ -333,12 +337,13 @@ func bearerToken(r *http.Request) string {
} }
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) { func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email) row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at FROM users WHERE email=?`, email)
var u User var u User
var passwordHash string var passwordHash string
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created string var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &created); err != nil { if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, "", errNotFound return nil, "", errNotFound
} }
@@ -346,6 +351,7 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled) u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil { if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, "", err return nil, "", err
@@ -354,11 +360,12 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
} }
func (a *App) userByID(ctx context.Context, id string) (*User, error) { func (a *App) userByID(ctx context.Context, id string) (*User, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,created_at FROM users WHERE id=?`, id) row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
var u User var u User
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created string var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil { if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, errNotFound return nil, errNotFound
} }
@@ -366,6 +373,7 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled) u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil { if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, err return nil, err
+1 -1
View File
@@ -251,7 +251,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
domain = "lanqin.local" domain = "lanqin.local"
} }
now := a.now().UTC() now := a.now().UTC()
subject := "LanQin Email SMTP 测试" subject := "NewSzxcn 邮箱 SMTP 测试"
bodyText := "这是一封 SMTP 测试邮件。" bodyText := "这是一封 SMTP 测试邮件。"
bodyHTML := "<p>这是一封 SMTP 测试邮件。</p>" bodyHTML := "<p>这是一封 SMTP 测试邮件。</p>"
if tpl, err := a.mailTemplate(r.Context(), smtpTestTemplateKey); err == nil { if tpl, err := a.mailTemplate(r.Context(), smtpTestTemplateKey); err == nil {
+1 -1
View File
@@ -112,7 +112,7 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "LanQin-Email-Webhook/1.0") req.Header.Set("User-Agent", "NewSzxcn-Email-Webhook/1.0")
req.Header.Set("X-LanQin-Webhook-Id", eventID) req.Header.Set("X-LanQin-Webhook-Id", eventID)
req.Header.Set("X-LanQin-Timestamp", timestamp) req.Header.Set("X-LanQin-Timestamp", timestamp)
req.Header.Set("X-LanQin-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) req.Header.Set("X-LanQin-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
+2 -2
View File
@@ -43,7 +43,7 @@ func defaultMailTemplates() []MailTemplate {
{ {
Key: "welcome", Key: "welcome",
Name: "欢迎邮件", Name: "欢迎邮件",
Subject: "欢迎使用 LanQin Email", Subject: "欢迎使用 NewSzxcn 邮箱",
BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。", BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。",
BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>", BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>",
UpdatedAt: now, UpdatedAt: now,
@@ -51,7 +51,7 @@ func defaultMailTemplates() []MailTemplate {
{ {
Key: smtpTestTemplateKey, Key: smtpTestTemplateKey,
Name: "SMTP 测试", Name: "SMTP 测试",
Subject: "LanQin Email SMTP 测试", Subject: "NewSzxcn 邮箱 SMTP 测试",
BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}", BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}",
BodyHTML: "<p>这是一封 SMTP 测试邮件。</p><p>发件人:{{from}}<br>收件人:{{to}}<br>时间:{{time}}<br>主机:{{publicHostname}}</p>", BodyHTML: "<p>这是一封 SMTP 测试邮件。</p><p>发件人:{{from}}<br>收件人:{{to}}<br>时间:{{time}}<br>主机:{{publicHostname}}</p>",
UpdatedAt: now, UpdatedAt: now,
+5 -3
View File
@@ -122,11 +122,12 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
} }
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) { func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,created_at FROM users WHERE id=?`, id) row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
var u User var u User
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var secret, created string var secret, created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &created); err != nil { if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &mailboxLimitOverride, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, "", errNotFound return nil, "", errNotFound
} }
@@ -134,6 +135,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled) u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil { if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, "", err return nil, "", err
@@ -172,7 +174,7 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
} }
respondJSON(w, http.StatusOK, map[string]any{ respondJSON(w, http.StatusOK, map[string]any{
"secret": secret, "secret": secret,
"otpauthUrl": totpProvisioningURI("LanQin Email", current.Email, secret), "otpauthUrl": totpProvisioningURI("NewSzxcn 邮箱", current.Email, secret),
}) })
} }
+1
View File
@@ -10,6 +10,7 @@ type User struct {
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
Protected bool `json:"protected"` Protected bool `json:"protected"`
TwoFactorEnabled bool `json:"twoFactorEnabled"` TwoFactorEnabled bool `json:"twoFactorEnabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride,omitempty"`
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
Limits PermissionLimits `json:"limits"` Limits PermissionLimits `json:"limits"`
PermissionGroupIDs []string `json:"permissionGroupIds"` PermissionGroupIDs []string `json:"permissionGroupIds"`
+15
View File
@@ -215,6 +215,21 @@ func nullableString(v string) any {
return v return v
} }
func nullableInt(v *int) any {
if v == nil {
return nil
}
return *v
}
func intPtrFromNull(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
value := int(v.Int64)
return &value
}
func parseTime(v string) time.Time { func parseTime(v string) time.Time {
t, _ := time.Parse(time.RFC3339Nano, v) t, _ := time.Parse(time.RFC3339Nano, v)
return t return t
+12 -11
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom" import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, Users } from "lucide-react" import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout" import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard" import { AuthGuard } from "@/components/auth-guard"
@@ -27,13 +27,14 @@ import {
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [ const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
{ key: "overview", label: "览", icon: <BarChart3 />, permissions: ["admin.overview.view"] }, { key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
{ key: "users", label: "用户", icon: <Users />, permissions: ["admin.users.view"] }, { key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
{ key: "permissionGroups", label: "权限", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] }, { key: "permissionGroups", label: "权限配额", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
{ key: "domains", label: "域名", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] }, { key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] }, { key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
{ key: "aliases", label: "别名转发", icon: <Copy />, permissions: ["admin.aliases.view"] }, { key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] }, { key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] }, { key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
] ]
@@ -64,7 +65,7 @@ function ProtectedContent() {
return ( return (
<SidebarProvider> <SidebarProvider>
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader className="border-b">
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild>
@@ -73,7 +74,7 @@ function ProtectedContent() {
<Mail className="size-4" /> <Mail className="size-4" />
</div> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">LanQin Email</span> <span className="truncate font-semibold">NewSzxcn </span>
</div> </div>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
@@ -106,7 +107,7 @@ function ProtectedContent() {
<span className="truncate text-xs text-muted-foreground">{user.email}</span> <span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div> </div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]"> <Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "超级管理员" : "普通用户"} {user.role === "admin" ? "管理员" : "普通用户"}
</Badge> </Badge>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
@@ -125,7 +126,7 @@ function ProtectedContent() {
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden"> <div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
<SidebarTrigger aria-label="打开导航" /> <SidebarTrigger aria-label="打开导航" />
<div className="min-w-0 flex-1 truncate text-sm font-semibold"> <div className="min-w-0 flex-1 truncate text-sm font-semibold">
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"} {isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "NewSzxcn 邮箱"}
</div> </div>
</div> </div>
<Outlet /> <Outlet />
+2 -2
View File
@@ -47,10 +47,10 @@ export type PermissionKey =
| "admin.templates.update" | "admin.templates.update"
| "admin.templates.reset" | "admin.templates.reset"
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string } 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 PermissionLimits = { maxAttachmentMb: number; maxMailboxCount: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
export type PermissionGroupSummary = { id: string; name: string } 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 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 User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string } export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: 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 AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
+2 -2
View File
@@ -125,8 +125,8 @@ export const api = {
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) }), 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"), defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), 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) }), createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; 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) }), updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; 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 }) }), 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" }), deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"), domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
+240 -89
View File
@@ -2,7 +2,7 @@ import * as React from "react"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import { useSearchParams } from "react-router-dom" import { useSearchParams } from "react-router-dom"
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { ArrowRight, BookOpen, CheckCircle2, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react" import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mail, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api" import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils" import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -28,17 +28,18 @@ import type { PermissionKey } from "@/lib/api-types"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings" type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
const sectionLabels: Record<Section, string> = { const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
overview: "概览", overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
users: "用户", users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
permissionGroups: "权限组", permissionGroups: { label: "权限配额", frontLabel: "账号配额", description: "配置前台菜单权限、发信频率、附件和邮箱创建额度。" },
domains: "域名", domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
mailboxes: "邮箱账号", mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
aliases: "别名转发", aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
messages: "全部邮件", messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
sendAudit: "发送审计", sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
settings: "系统设置", settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
} }
const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string>
const sectionKeys = Object.keys(sectionLabels) as Section[] const sectionKeys = Object.keys(sectionLabels) as Section[]
const sectionPermissions: Record<Section, PermissionKey[]> = { const sectionPermissions: Record<Section, PermissionKey[]> = {
overview: ["admin.overview.view"], overview: ["admin.overview.view"],
@@ -51,11 +52,12 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
sendAudit: ["admin.messages.view"], sendAudit: ["admin.messages.view"],
settings: ["admin.settings.view", "admin.templates.view"], settings: ["admin.settings.view", "admin.templates.view"],
} }
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email" const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5" const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const projectTag = import.meta.env.VITE_APP_VERSION || "" const projectTag = import.meta.env.VITE_APP_VERSION || ""
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "") 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 } const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
const defaultMailboxLimitOverride = 9
export function AdminPage() { export function AdminPage() {
const me = useMe() const me = useMe()
@@ -90,17 +92,15 @@ export function AdminPage() {
return ( return (
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh"> <ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
<main className="p-4 sm:p-6"> <main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <AdminPageHeader section={section} />
<h1 className="text-2xl font-semibold tracking-tight">{sectionLabels[section]}</h1>
</div>
{section === "overview" && canOverview && ( {section === "overview" && canOverview && (
<div className="mb-6 grid gap-4 md:grid-cols-4"> <div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat icon={<Users />} label="用户" value={overview.data?.users || 0} /> <Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
<Stat icon={<Globe2 />} label="域名" value={overview.data?.domains || 0} /> <Stat icon={<Globe2 />} label="邮件域名" value={overview.data?.domains || 0} />
<Stat icon={<Mailbox />} label="邮箱账号" value={overview.data?.mailboxes || 0} /> <Stat icon={<Mailbox />} label="邮箱" value={overview.data?.mailboxes || 0} />
<Stat icon={<ShieldCheck />} label="存储" value={formatBytes(overview.data?.storageBytes || 0)} /> <Stat icon={<ShieldCheck />} label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} />
</div> </div>
)} )}
@@ -117,6 +117,27 @@ export function AdminPage() {
</ScrollArea> </ScrollArea>
) )
} }
function AdminPageHeader({ section }: { section: Section }) {
const meta = sectionMeta[section]
return (
<div className="mb-4 border-b pb-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span></span>
<span className="h-1 w-1 rounded-full bg-muted-foreground/50" />
<span>{meta.frontLabel}</span>
</div>
<h1 className="text-[20px] font-semibold leading-7 tracking-tight">{meta.label}</h1>
<p className="mt-1 text-sm leading-5 text-muted-foreground">{meta.description}</p>
</div>
<Badge variant="outline" className="h-7 rounded-md px-2.5 font-normal">NewSzxcn</Badge>
</div>
</div>
)
}
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 }) { 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)) const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section))
return ( return (
@@ -125,9 +146,9 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
<Card> <Card>
<CardHeader><CardTitle></CardTitle></CardHeader> <CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<InfoBox label="活跃用户" value={overview?.activeUsers || 0} /> <InfoBox label="活跃账号" value={overview?.activeUsers || 0} />
<InfoBox label="活跃邮箱" value={overview?.activeMailboxes || 0} /> <InfoBox label="活跃邮箱" value={overview?.activeMailboxes || 0} />
<InfoBox label="别名转发" value={overview?.aliases || 0} /> <InfoBox label="邮件转发" value={overview?.aliases || 0} />
<InfoBox label="未读邮件" value={overview?.unreadMessages || 0} /> <InfoBox label="未读邮件" value={overview?.unreadMessages || 0} />
</CardContent> </CardContent>
</Card> </Card>
@@ -161,7 +182,7 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
<InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} /> <InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} />
<InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} /> <InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} />
<InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} /> <InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} />
<InfoLine label="用户自助申请" value={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} /> <InfoLine label="自助申请邮箱" value={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -177,7 +198,7 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
return [ return [
{ key: "domain", title: "添加邮件域名", detail: hasDomain ? `${domains.length} 个域名已添加` : "先添加 example.com 这样的邮件域名", done: hasDomain, section: "domains" as Section }, { 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: "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: "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 }, { key: "mail", title: "完成收发测试", detail: hasMail ? `${overview?.messages || 0} 封邮件已入库` : "发送或接收一封测试邮件", done: hasMail, section: "messages" as Section },
] ]
@@ -205,12 +226,12 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled) const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
return matchesKeyword && matchesRole && matchesStatus return matchesKeyword && matchesRole && matchesStatus
}) })
const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "用户已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "账号已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} />} {canCreate && <CreateUserDialog permissionGroups={permissionGroups} />}
</div> </div>
</CardHeader> </CardHeader>
@@ -218,13 +239,13 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<div className="flex flex-col gap-3 lg:flex-row"> <div className="flex flex-col gap-3 lg:flex-row">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <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" /> <Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号、邮箱、显示名称" className="pl-9" />
</div> </div>
<Select value={roleFilter} onValueChange={setRoleFilter}> <Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="lg:w-36"><SelectValue /></SelectTrigger> <SelectTrigger className="lg:w-36"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all"></SelectItem>
<SelectItem value="admin"></SelectItem> <SelectItem value="admin"></SelectItem>
<SelectItem value="user"></SelectItem> <SelectItem value="user"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -245,7 +266,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
<div className="truncate font-medium">{user.displayName}</div> <div className="truncate font-medium">{user.displayName}</div>
<div className="truncate text-xs text-muted-foreground">{user.email}</div> <div className="truncate text-xs text-muted-foreground">{user.email}</div>
</div> </div>
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} /> <UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<RoleBadge user={user} /> <RoleBadge user={user} />
@@ -259,7 +280,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
<Table> <Table>
<TableHeader><TableRow><TableHead></TableHead><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 className="w-[22rem]"></TableHead><TableHead></TableHead><TableHead></TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
<TableBody> <TableBody>
{filteredUsers.map((user) => ( {filteredUsers.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
@@ -269,16 +290,16 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
</TableCell> </TableCell>
<TableCell><RoleBadge user={user} /></TableCell> <TableCell><RoleBadge user={user} /></TableCell>
<TableCell><UserPermissionGroupsCell user={user} /></TableCell> <TableCell><UserPermissionGroupsCell user={user} /></TableCell>
<TableCell><UserMailboxCell user={user} /></TableCell> <TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell> <TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell> <TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell> <TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{filteredUsers.length === 0 && <Empty text="没有匹配的用户" />} {filteredUsers.length === 0 && <Empty text="没有匹配的账号" />}
</CardContent> </CardContent>
<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()} /> <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> </Card>
@@ -301,7 +322,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
onSuccess: () => { onSuccess: () => {
setPendingConfirm(null) setPendingConfirm(null)
invalidateAdmin(qc) invalidateAdmin(qc)
toast({ title: "权限已删除" }) toast({ title: "权限配额已删除" })
}, },
onError: (e) => toast({ title: "删除失败", description: e.message }), onError: (e) => toast({ title: "删除失败", description: e.message }),
}) })
@@ -316,14 +337,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
{canCreate && <PermissionGroupDialog catalog={catalog} />} {canCreate && <PermissionGroupDialog catalog={catalog} />}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <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" /> <Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限配额、说明或权限键" className="pl-9" />
</div> </div>
<div className="grid gap-3 lg:grid-cols-2"> <div className="grid gap-3 lg:grid-cols-2">
{filtered.map((group) => ( {filtered.map((group) => (
@@ -341,14 +362,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
{(canUpdate || canDelete) && <DropdownMenu> {(canUpdate || canDelete) && <DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger> <DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}></DropdownMenuItem> <DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}></DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
disabled={!isDeletable(group) || !canDelete} disabled={!isDeletable(group) || !canDelete}
onSelect={() => setPendingConfirm({ title: "删除权限", description: `${group.name} 删除后不能再分配给用户`, confirmText: "删除权限", onConfirm: () => remove.mutate(group.id) })} onSelect={() => setPendingConfirm({ title: "删除权限配额", description: `${group.name} 删除后不能再分配给账号`, confirmText: "删除权限配额", onConfirm: () => remove.mutate(group.id) })}
> >
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu>} </DropdownMenu>}
@@ -358,7 +379,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
</div> </div>
))} ))}
</div> </div>
{filtered.length === 0 && <Empty text="暂无匹配的权限" />} {filtered.length === 0 && <Empty text="暂无匹配的权限配额" />}
</CardContent> </CardContent>
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />} {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()} /> <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()} />
@@ -395,20 +416,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
onSuccess: () => { onSuccess: () => {
invalidateAdmin(qc) invalidateAdmin(qc)
setDialogOpen(false) setDialogOpen(false)
toast({ title: group ? "权限已更新" : "权限已创建" }) toast({ title: group ? "权限配额已更新" : "权限配额已创建" })
}, },
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }), onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
}) })
const trigger = group ? null : ( const trigger = group ? null : (
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4" /></Button> <Button size="sm"><Plus className="h-4 w-4" /></Button>
</DialogTrigger> </DialogTrigger>
) )
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger} {trigger}
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl"> <DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader><DialogTitle>{group ? "编辑权限" : "创建权限"}</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>{group ? "编辑权限配额" : "创建权限配额"}</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}> <form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" /> <Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
@@ -481,6 +502,10 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
<span className="text-xs text-muted-foreground"> 0 </span> <span className="text-xs text-muted-foreground"> 0 </span>
</div> </div>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<div className="space-y-2">
<Label></Label>
<Input type="number" min={0} value={value.maxMailboxCount} onChange={(event) => update("maxMailboxCount", event.target.value)} />
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label> MB</Label> <Label> MB</Label>
<Input type="number" min={0} value={value.maxAttachmentMb} onChange={(event) => update("maxAttachmentMb", event.target.value)} /> <Input type="number" min={0} value={value.maxAttachmentMb} onChange={(event) => update("maxAttachmentMb", event.target.value)} />
@@ -525,6 +550,7 @@ function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
return ( return (
<div className="mt-3 flex flex-wrap gap-1.5"> <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"> {limitText(value.maxAttachmentMb, "MB")}</Badge>
<Badge variant="secondary" className="font-normal"> {limitText(value.maxMailboxCount, "个")}</Badge>
<Badge variant="secondary" className="font-normal">SMTP {limitText(value.smtpDailyLimit, "封")}</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">SMTP {limitText(value.smtpMinuteLimit, "封")}</Badge>
<Badge variant="secondary" className="font-normal">IMAP {limitText(value.imapMinuteLimit, "次")}</Badge> <Badge variant="secondary" className="font-normal">IMAP {limitText(value.imapMinuteLimit, "次")}</Badge>
@@ -582,7 +608,7 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge> <Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
{canViewDNS && <DomainDNSDialog domain={domain} />} {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>} {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>} {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>
</div> </div>
))} ))}
@@ -621,7 +647,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
{canCreate && <CreateMailboxDialog domains={domains} users={users} />} {canCreate && <CreateMailboxDialog domains={domains} users={users} />}
</div> </div>
</CardHeader> </CardHeader>
@@ -646,7 +672,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
<Table> <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 className="w-16"></TableHead></TableRow></TableHeader>
<TableBody> <TableBody>
{mailboxes.map((mailbox) => ( {mailboxes.map((mailbox) => (
<TableRow key={mailbox.id}> <TableRow key={mailbox.id}>
@@ -661,7 +687,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{mailboxes.length === 0 && <Empty text="暂无邮箱账号" />} {mailboxes.length === 0 && <Empty text="暂无邮箱" />}
</CardContent> </CardContent>
<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()} /> <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> </Card>
@@ -677,13 +703,13 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
const canCreate = hasPermission(user, "admin.aliases.create") const canCreate = hasPermission(user, "admin.aliases.create")
const canUpdate = hasPermission(user, "admin.aliases.update") const canUpdate = hasPermission(user, "admin.aliases.update")
const canDelete = hasPermission(user, "admin.aliases.delete") 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 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 }) }) const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "转发已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle>/</CardTitle> <CardTitle></CardTitle>
{canCreate && <CreateAliasDialog domains={domains} />} {canCreate && <CreateAliasDialog domains={domains} />}
</div> </div>
</CardHeader> </CardHeader>
@@ -696,7 +722,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
<div className="truncate font-medium">{alias.source}</div> <div className="truncate font-medium">{alias.source}</div>
<div className="truncate text-xs text-muted-foreground">{alias.destination}</div> <div className="truncate text-xs text-muted-foreground">{alias.destination}</div>
</div> </div>
<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} /> <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>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge> <Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge>
@@ -715,13 +741,13 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
<TableCell>{alias.destination}</TableCell> <TableCell>{alias.destination}</TableCell>
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</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><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></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> <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> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{aliases.length === 0 && <Empty text="暂无别名转发" />} {aliases.length === 0 && <Empty text="暂无邮件转发" />}
</CardContent> </CardContent>
<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()} /> <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> </Card>
@@ -880,7 +906,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" /></CardTitle> <CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" /></CardTitle>
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "send-audit"] })}> <Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "send-audit"] })}>
<RefreshCcw className="h-4 w-4" /> <RefreshCcw className="h-4 w-4" />
</Button> </Button>
@@ -955,7 +981,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
</Table> </Table>
</div> </div>
{audit.isLoading && <Empty text="加载中..." />} {audit.isLoading && <Empty text="加载中..." />}
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送审计" />} {!audit.isLoading && items.length === 0 && <Empty text="暂无发送记录" />}
{!audit.isLoading && audit.hasNextPage && ( {!audit.isLoading && audit.hasNextPage && (
<div className="flex justify-center"> <div className="flex justify-center">
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}> <Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
@@ -1157,7 +1183,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<SwitchRow label="无人收件" checked={catchAllEnabled} onCheckedChange={setCatchAllEnabled} /> <SwitchRow label="无人收件" checked={catchAllEnabled} onCheckedChange={setCatchAllEnabled} />
<Separator /> <Separator />
<SwitchRow label="用户自助申请邮箱" checked={userMailboxApplyEnabled} onCheckedChange={setUserMailboxApplyEnabled} /> <SwitchRow label="账号自助申请邮箱" checked={userMailboxApplyEnabled} onCheckedChange={setUserMailboxApplyEnabled} />
{userMailboxApplyEnabled && ( {userMailboxApplyEnabled && (
<div className="space-y-5 border-t pt-5"> <div className="space-y-5 border-t pt-5">
<div className="space-y-3"> <div className="space-y-3">
@@ -1202,7 +1228,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground"> <div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
IMAP IMAP
</div> </div>
<SwitchRow label="启用外部 IMAP" checked={externalImapEnabled} onCheckedChange={setExternalImapEnabled} /> <SwitchRow label="启用外部 IMAP" checked={externalImapEnabled} onCheckedChange={setExternalImapEnabled} />
{externalImapEnabled && ( {externalImapEnabled && (
@@ -1372,7 +1398,7 @@ function AboutProjectCard() {
const latestRelease = useQuery({ const latestRelease = useQuery({
queryKey: ["github", "latest-release"], queryKey: ["github", "latest-release"],
queryFn: async () => { queryFn: async () => {
const res = await fetch("https://api.github.com/repos/LanQin996/LanQin-Email/releases/latest") const res = await fetch("https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest")
if (!res.ok) throw new Error("rate limited or unavailable") if (!res.ok) throw new Error("rate limited or unavailable")
return res.json() as Promise<{ tag_name: string; html_url: string }> return res.json() as Promise<{ tag_name: string; html_url: string }>
}, },
@@ -1601,7 +1627,7 @@ function AdminMessageDialog({ message, loading, open, onOpenChange }: { message?
<div className="space-y-5"> <div className="space-y-5">
<div className="grid gap-3 rounded-lg border p-4 text-sm md:grid-cols-2"> <div className="grid gap-3 rounded-lg border p-4 text-sm md:grid-cols-2">
<MessageMeta label="所属邮箱" value={message.mailboxAddress || message.recipientAddress || ""} /> <MessageMeta label="所属邮箱" value={message.mailboxAddress || message.recipientAddress || ""} />
<MessageMeta label="所属用户" value={message.ownerEmail || ""} /> <MessageMeta label="所属账号" value={message.ownerEmail || ""} />
<MessageMeta label="发件人" value={adminSenderTitle(message)} /> <MessageMeta label="发件人" value={adminSenderTitle(message)} />
<MessageMeta label="收件人" value={message.recipientAddress || message.to?.join(", ") || ""} /> <MessageMeta label="收件人" value={message.recipientAddress || message.to?.join(", ") || ""} />
<MessageMeta label="文件夹" value={folderName(message.folder)} /> <MessageMeta label="文件夹" value={folderName(message.folder)} />
@@ -1687,12 +1713,80 @@ function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className=
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) } function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
function UserMailboxCell({ user }: { user: AdminUser }) { function UserMailboxCell({ user }: { user: AdminUser }) {
const { toast } = useToast()
const loginAddress = user.email
const mailboxes = user.mailboxes || [] const mailboxes = user.mailboxes || []
if (mailboxes.length === 0) return <span className="text-muted-foreground"></span> const [mailboxQuery, setMailboxQuery] = React.useState("")
const normalizedQuery = mailboxQuery.trim().toLowerCase()
const sortedMailboxes = React.useMemo(() => {
return Array.from(new Set(mailboxes)).sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))
}, [mailboxes])
const [selectedAddress, setSelectedAddress] = React.useState(loginAddress)
React.useEffect(() => {
if (selectedAddress === loginAddress || sortedMailboxes.includes(selectedAddress)) return
setSelectedAddress(loginAddress)
}, [loginAddress, selectedAddress, sortedMailboxes])
const filteredMailboxes = React.useMemo(() => {
if (!normalizedQuery) return sortedMailboxes
return sortedMailboxes.filter((mailbox) => mailbox.toLowerCase().includes(normalizedQuery))
}, [normalizedQuery, sortedMailboxes])
const limit = user.role === "admin" ? "不限" : limitText(user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride, "个")
const quota = <div className="text-[11px] text-muted-foreground"> {user.mailboxCount}/{limit}</div>
async function copyMailbox(address: string) {
if (!address) return
await navigator.clipboard.writeText(address)
toast({ title: "邮箱地址已复制", description: address })
}
return ( return (
<div className="flex max-w-md flex-wrap gap-1"> <div className="w-full max-w-[21rem] space-y-1">
{mailboxes.slice(0, 2).map((mailbox) => <Badge key={mailbox} variant="outline" className="font-normal">{mailbox}</Badge>)} <div className="flex min-w-0 items-center gap-1.5">
{mailboxes.length > 2 && <Badge variant="secondary">+{mailboxes.length - 2}</Badge>} <DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background"
title={selectedAddress}
>
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{selectedAddress}</span>
{sortedMailboxes.length > 0 && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">{sortedMailboxes.length} </span>}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[21rem] max-w-[calc(100vw-32px)] p-1">
<div className="px-1 pb-1">
<div className="relative">
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
autoFocus
value={mailboxQuery}
onChange={(event) => setMailboxQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder="搜索邮箱..."
className="h-8 rounded-md bg-background pl-8 pr-2 text-[13px] shadow-none"
/>
</div>
</div>
{filteredMailboxes.map((mailbox) => (
<DropdownMenuItem
key={mailbox}
onSelect={() => setSelectedAddress(mailbox)}
className={cn("h-8 min-w-0 gap-2 rounded-sm px-2 text-[13px] font-normal", selectedAddress === mailbox && "bg-accent text-accent-foreground")}
>
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate" title={mailbox}>{mailbox}</span>
</DropdownMenuItem>
))}
{sortedMailboxes.length === 0 && <DropdownMenuItem disabled className="h-8 px-2 text-[13px] font-normal"></DropdownMenuItem>}
{sortedMailboxes.length > 0 && filteredMailboxes.length === 0 && <DropdownMenuItem disabled className="h-8 px-2 text-[13px] font-normal"></DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background" disabled={!selectedAddress} onClick={() => copyMailbox(selectedAddress)} aria-label="复制邮箱地址" title="复制邮箱地址">
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{quota}
</div> </div>
) )
} }
@@ -1721,7 +1815,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
} }
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <Label></Label>
<div className="grid gap-2 md:grid-cols-2"> <div className="grid gap-2 md:grid-cols-2">
{groups.map((group) => { {groups.map((group) => {
const checked = value.includes(group.id) const checked = value.includes(group.id)
@@ -1736,7 +1830,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
) )
})} })}
</div> </div>
{groups.length === 0 && <Empty text="暂无可分配权限" />} {groups.length === 0 && <Empty text="暂无可分配权限配额" />}
</div> </div>
) )
} }
@@ -1744,7 +1838,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
function RoleBadge({ user }: { user: AdminUser }) { function RoleBadge({ user }: { user: AdminUser }) {
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge> <Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
{user.protected && <Badge variant="outline"></Badge>} {user.protected && <Badge variant="outline"></Badge>}
</div> </div>
) )
@@ -1761,7 +1855,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
const canResetPassword = hasPermission(currentUser, "admin.users.reset_password") const canResetPassword = hasPermission(currentUser, "admin.users.reset_password")
const update = useMutation({ const update = useMutation({
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => 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: "用户已更新" }) }, onSuccess: () => { invalidateAdmin(qc); toast({ title: "账号已更新" }) },
onError: (e) => toast({ title: "更新失败", description: e.message }), onError: (e) => toast({ title: "更新失败", description: e.message }),
}) })
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) { function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) {
@@ -1774,7 +1868,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
}) })
} }
if (!canUpdate && !canResetPassword && !onDelete) return null 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} />}</> 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({ permissionGroups }: { permissionGroups: PermissionGroup[] }) { function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) {
@@ -1785,23 +1879,32 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
const [status, setStatus] = React.useState("active") const [status, setStatus] = React.useState("active")
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([]) const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
const create = useMutation({ 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", permissionGroupIds: role === "user" ? permissionGroupIds : [] }), mutationFn: (form: FormData) => api.createUser({
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "用户已创建" }) }, email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),
password: String(form.get("password") || ""),
role,
disabled: status === "disabled",
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form) : undefined,
permissionGroupIds: role === "user" ? permissionGroupIds : [],
}),
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "账号已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }), onError: (e) => toast({ title: "创建失败", description: e.message }),
}) })
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4" /></Button></DialogTrigger> <DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4" /></Button></DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}> <form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
<Field name="email" label="登录邮箱" type="email" placeholder="user@example.com" /> <Field name="email" label="登录邮箱" type="email" placeholder="user@example.com" />
<Field name="displayName" label="显示名称" placeholder="用户名称" /> <Field name="displayName" label="显示名称" placeholder="账号名称" />
<Field name="password" label="初始密码" type="password" minLength={8} /> <Field name="password" label="初始密码" type="password" minLength={8} />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "超级管理员"]]} /> <SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} /> <SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
</div> </div>
{role === "user" && <MailboxLimitField defaultValue={defaultMailboxLimitOverride} />}
{role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />} {role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter> <DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
</form> </form>
@@ -1818,26 +1921,61 @@ function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: Mail
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) { function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
if (!onToggle && !onDelete) return null 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> 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, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) { 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)) const qc = useQueryClient()
React.useEffect(() => { setRole(user.role); setDisabled(user.disabled ? "disabled" : "active"); setPermissionGroupIds(assignableUserGroupIDs(user)) }, [user, open]) const { toast } = useToast()
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 }) }) const [role, setRole] = React.useState(user.role)
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> 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",
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form, effectiveMailboxLimit(user)) : undefined,
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 && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
{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 }) { function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) }) const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="用户" value={user.email} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog> return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="账号" value={user.email} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
} }
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) { function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
const qc = useQueryClient(); const { toast } = useToast(); const [userId, setUserId] = React.useState(mailbox.userId); const [status, setStatus] = React.useState(mailbox.status) const qc = useQueryClient(); const { toast } = useToast(); const [userId, setUserId] = React.useState(mailbox.userId); const [status, setStatus] = React.useState(mailbox.status)
React.useEffect(() => { setUserId(mailbox.userId); setStatus(mailbox.status) }, [mailbox, open]) React.useEffect(() => { setUserId(mailbox.userId); setStatus(mailbox.status) }, [mailbox, open])
const mut = useMutation({ mutationFn: (form: FormData) => api.updateMailbox(mailbox.id, { userId, displayName: String(form.get("displayName") || ""), quotaMb: Number(form.get("quotaMb") || 1024), status }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "邮箱已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) }) const mut = useMutation({ mutationFn: (form: FormData) => api.updateMailbox(mailbox.id, { userId, displayName: String(form.get("displayName") || ""), quotaMb: Number(form.get("quotaMb") || 1024), status }), 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="address" label="邮箱地址" value={mailbox.address} readOnly /><SelectField label="归属用户" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /><div className="grid grid-cols-2 gap-3"><Field name="displayName" label="显示名称" defaultValue={mailbox.displayName} /><Field name="quotaMb" label="配额 MB" type="number" defaultValue={String(mailbox.quotaMb)} /></div><SelectField label="状态" value={status} onValueChange={setStatus} items={[['active','启用'],['disabled','停用']]} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog> return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="address" label="邮箱地址" value={mailbox.address} readOnly /><SelectField label="归属账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /><div className="grid grid-cols-2 gap-3"><Field name="displayName" label="显示名称" defaultValue={mailbox.displayName} /><Field name="quotaMb" label="配额 MB" type="number" defaultValue={String(mailbox.quotaMb)} /></div><SelectField label="状态" value={status} onValueChange={setStatus} items={[['active','启用'],['disabled','停用']]} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
} }
function CreateDomainDialog() { function CreateDomainDialog() {
@@ -1850,14 +1988,14 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("") const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId]) React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerEmail: String(form.get("ownerEmail") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) }) const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", 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[] }) { function CreateAliasDialog({ domains }: { domains: Domain[] }) {
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState("") const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId]) React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId])
const mut = useMutation({ mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "别名已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) }) const mut = useMutation({ mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "转发已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline"><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} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog> return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline"><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} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
} }
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) { function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
@@ -1930,8 +2068,21 @@ 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 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 MailboxLimitField({ defaultValue }: { defaultValue: number }) {
return (
<div className="space-y-2">
<Label></Label>
<Input name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
<div className="text-xs text-muted-foreground"> 9 0 </div>
</div>
)
}
function mailboxLimitFromForm(form: FormData, fallback = defaultMailboxLimitOverride) {
const value = Number(form.get("mailboxLimitOverride") || fallback)
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback
}
function effectiveMailboxLimit(user: AdminUser) {
return user.mailboxLimitOverride ?? user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride
}
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 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> } 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> }
+1 -2
View File
@@ -38,7 +38,7 @@ export function LoginPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10"> <div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]"> <div className="w-full max-w-[420px]">
<div className="mb-7 text-center"> <div className="mb-7 text-center">
<h1 className="text-3xl font-semibold tracking-tight">LanQin Email</h1> <h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
</div> </div>
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7"> <div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
<div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground">
@@ -85,4 +85,3 @@ export function LoginPage() {
</div> </div>
) )
} }
+19 -8
View File
@@ -1076,7 +1076,7 @@ export function MailPage() {
<SidebarHeader className={cn("pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}> <SidebarHeader className={cn("pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}>
<AccountHeader <AccountHeader
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
name={me.data?.user.displayName || selectedMailbox?.address || "LanQin"} name={me.data?.user.displayName || selectedMailbox?.address || "NewSzxcn"}
email={me.data?.user.email || selectedMailbox?.address} email={me.data?.user.email || selectedMailbox?.address}
darkMode={darkMode} darkMode={darkMode}
onToggleTheme={() => setDarkMode((value) => !value)} onToggleTheme={() => setDarkMode((value) => !value)}
@@ -1084,7 +1084,7 @@ export function MailPage() {
onLanguageChange={setLanguage} onLanguageChange={setLanguage}
onSettings={openSettings} onSettings={openSettings}
/> />
<div className={cn("mt-2 flex gap-1.5", sidebarCollapsed && "justify-center")}> <div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : "grid grid-cols-[minmax(0,1fr)_2rem]")}>
<MailboxSwitcher <MailboxSwitcher
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
mailboxes={mailboxList.data?.items || []} mailboxes={mailboxList.data?.items || []}
@@ -1094,8 +1094,19 @@ export function MailPage() {
unreadCount={mailboxUnreadCount} unreadCount={mailboxUnreadCount}
onSelect={switchMailbox} onSelect={switchMailbox}
/> />
{!sidebarCollapsed && !isAllMailboxSelected && ( {!sidebarCollapsed && (
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background" onClick={copyCurrentMailbox} disabled={!selectedMailbox} aria-label="复制邮箱地址"> <Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background", isAllMailboxSelected && "invisible pointer-events-none")}
onClick={copyCurrentMailbox}
disabled={!selectedMailbox || isAllMailboxSelected}
aria-label="复制邮箱地址"
aria-hidden={isAllMailboxSelected}
tabIndex={isAllMailboxSelected ? -1 : 0}
title="复制邮箱地址"
>
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
@@ -3117,7 +3128,7 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail
align="start" align="start"
className={cn( className={cn(
"max-w-[calc(100vw-32px)] p-1", "max-w-[calc(100vw-32px)] p-1",
collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-[var(--radix-dropdown-menu-trigger-width)]" collapsed ? "w-[204px]" : "w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)] min-w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)]"
)} )}
> >
{mailboxes.length > 0 && ( {mailboxes.length > 0 && (
@@ -3622,7 +3633,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
const allowed = maxAttachmentBytes > 0 ? nextFiles.filter((file) => file.size <= maxAttachmentBytes) : nextFiles const allowed = maxAttachmentBytes > 0 ? nextFiles.filter((file) => file.size <= maxAttachmentBytes) : nextFiles
const blockedCount = nextFiles.length - allowed.length const blockedCount = nextFiles.length - allowed.length
if (blockedCount > 0) { if (blockedCount > 0) {
toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` }) toast({ title: "附件超过配额上限", description: `当前单个附件上限 ${maxAttachmentText}` })
} }
if (allowed.length > 0) { if (allowed.length > 0) {
setAttachmentsTouched(true) setAttachmentsTouched(true)
@@ -3633,7 +3644,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts,
function attachmentsWithinLimit() { function attachmentsWithinLimit() {
if (maxAttachmentBytes <= 0) return true if (maxAttachmentBytes <= 0) return true
if (files.every((file) => file.size <= maxAttachmentBytes)) return true if (files.every((file) => file.size <= maxAttachmentBytes)) return true
toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` }) toast({ title: "附件超过配额上限", description: `当前单个附件上限 ${maxAttachmentText}` })
return false return false
} }
@@ -4628,7 +4639,7 @@ function scheduleToIcs(schedule: ScheduleDraft) {
const lines = [ const lines = [
"BEGIN:VCALENDAR", "BEGIN:VCALENDAR",
"VERSION:2.0", "VERSION:2.0",
"PRODID:-//LanQin Email//Webmail//CN", "PRODID:-//NewSzxcn Email//Webmail//CN",
"CALSCALE:GREGORIAN", "CALSCALE:GREGORIAN",
"METHOD:PUBLISH", "METHOD:PUBLISH",
"BEGIN:VEVENT", "BEGIN:VEVENT",
+5 -6
View File
@@ -320,7 +320,7 @@ export function ProfilePage() {
const sidebarContent = ( const sidebarContent = (
<aside className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card"> <aside className="flex h-full w-[256px] shrink-0 flex-col border-r border-border bg-card">
<div className="h-[64px] border-b"> <div className="h-[64px] border-b">
<AccountHeader name={user.displayName || selectedMailbox?.address || "LanQin"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} /> <AccountHeader name={user.displayName || selectedMailbox?.address || "NewSzxcn"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
</div> </div>
<nav className="min-h-0 flex-1 overflow-y-auto p-2"> <nav className="min-h-0 flex-1 overflow-y-auto p-2">
<div className="px-2 pb-2 pt-2 text-xs font-medium text-muted-foreground"></div> <div className="px-2 pb-2 pt-2 text-xs font-medium text-muted-foreground"></div>
@@ -603,7 +603,6 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
<SettingsCard title="账号信息"> <SettingsCard title="账号信息">
<div className="space-y-5"> <div className="space-y-5">
<InfoLine label="用户名" value={accountName} /> <InfoLine label="用户名" value={accountName} />
<InfoLine label="NewSzxcn ID" value={user.id.slice(0, 8)} />
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center"> <div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
<Label className="text-base font-normal text-muted-foreground"></Label> <Label className="text-base font-normal text-muted-foreground"></Label>
<select className="h-[29px] rounded-md border border-input bg-background px-2 text-sm outline-none focus:ring-1 focus:ring-ring sm:ml-auto sm:w-[236px]" defaultValue="Asia/Shanghai"> <select className="h-[29px] rounded-md border border-input bg-background px-2 text-sm outline-none focus:ring-1 focus:ring-ring sm:ml-auto sm:w-[236px]" defaultValue="Asia/Shanghai">
@@ -628,7 +627,7 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
<SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground"></span>}> <SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground"></span>}>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<QuotaBox title="邮箱创建" lines={[`当前拥有 ${mailboxes.length} 个邮箱`, "近 7 天创建/分配按权限组计算", "达到上限后会触发冷却"]} highlight="等级额度" /> <QuotaBox title="邮箱创建" lines={[`当前拥有 ${mailboxes.length} 个邮箱`, user.limits?.maxMailboxCount ? `最多可添加 ${user.limits.maxMailboxCount} 个邮箱` : "管理员不限制邮箱数量", user.limits?.maxMailboxCount ? "达到上限后不可继续自助申请" : "可继续添加邮箱"]} highlight={user.limits?.maxMailboxCount ? "普通额度" : "管理员无限"} />
<QuotaBox title="验证邮箱" lines={["已绑定主账号邮箱", "可继续添加验证邮箱"]} /> <QuotaBox title="验证邮箱" lines={["已绑定主账号邮箱", "可继续添加验证邮箱"]} />
<QuotaBox title="发信频率" lines={[`每 24 小时 最多 ${user.limits?.smtpDailyLimit || "不限"} 封邮件`, `每分钟最多 ${user.limits?.smtpMinuteLimit || "不限"}`]} /> <QuotaBox title="发信频率" lines={[`每 24 小时 最多 ${user.limits?.smtpDailyLimit || "不限"} 封邮件`, `每分钟最多 ${user.limits?.smtpMinuteLimit || "不限"}`]} />
<QuotaBox title="协议访问频率" lines={[`IMAP:每 1 分钟 最多 ${user.limits?.imapMinuteLimit || "不限"} 次命令`, `POP3:每 1 分钟 最多 ${user.limits?.pop3MinuteLimit || "不限"} 次命令`]} /> <QuotaBox title="协议访问频率" lines={[`IMAP:每 1 分钟 最多 ${user.limits?.imapMinuteLimit || "不限"} 次命令`, `POP3:每 1 分钟 最多 ${user.limits?.pop3MinuteLimit || "不限"} 次命令`]} />
@@ -1184,7 +1183,7 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, show
<ShieldCheck className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
</div> </div>
<Badge>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge> <Badge>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm"> <div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span></span> <span></span>
@@ -2090,7 +2089,7 @@ function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelec
</div> </div>
</> </>
) : ( ) : (
<EmptyState text="暂无邮箱账号,创建邮箱后可查看客户端配置" /> <EmptyState text="暂无邮箱,创建邮箱后可查看客户端配置" />
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -2116,7 +2115,7 @@ function ClientConfigRow({ label, value, security, onCopy }: { label: string; va
const apiTokenScopeOptions = [ const apiTokenScopeOptions = [
["messages:send", "发送邮件"], ["messages:read", "读取邮件与投递状态"], ["messages:manage", "重试或取消发送"], ["messages:send", "发送邮件"], ["messages:read", "读取邮件与投递状态"], ["messages:manage", "重试或取消发送"],
["domains:read", "查看域名"], ["domains:write", "管理域名"], ["mailboxes:read", "查看邮箱"], ["mailboxes:write", "管理邮箱"], ["domains:read", "查看域名"], ["domains:write", "管理域名"], ["mailboxes:read", "查看邮箱"], ["mailboxes:write", "管理邮箱"],
["dns:read", "查看 DNS"], ["dns:check", "执行 DNS 检测"], ["aliases:read", "查看别名"], ["aliases:write", "管理别名"], ["dns:read", "查看 DNS"], ["dns:check", "执行 DNS 检测"], ["aliases:read", "查看邮件转发"], ["aliases:write", "管理邮件转发"],
] as const ] as const
function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelete, onCopy }: { items: APIToken[]; loading: boolean; pending: boolean; onCreate: (payload: { name: string; expiresAt?: string; scopes: string[] }) => Promise<{ token: string; item: APIToken }>; onUpdate: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => void; onDelete: (id: string) => void; onCopy: (text: string) => void }) { function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelete, onCopy }: { items: APIToken[]; loading: boolean; pending: boolean; onCreate: (payload: { name: string; expiresAt?: string; scopes: string[] }) => Promise<{ token: string; item: APIToken }>; onUpdate: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => void; onDelete: (id: string) => void; onCopy: (text: string) => void }) {
+1 -1
View File
@@ -65,7 +65,7 @@ export function RegisterPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10"> <div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]"> <div className="w-full max-w-[420px]">
<div className="mb-7 text-center"> <div className="mb-7 text-center">
<h1 className="text-3xl font-semibold tracking-tight">LanQin Email</h1> <h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
</div> </div>
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7"> <div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
<div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground">