diff --git a/README.en.md b/README.en.md
index 17fa0b9..52f5252 100644
--- a/README.en.md
+++ b/README.en.md
@@ -1,244 +1,49 @@
-# LanQin Email
+# NewSzxcn-Email
-[](./README.en.md)
-[](./README.md)
+NewSzxcn-Email is a self-hosted, manageable, ready-to-run open-source email system.
-
-
-
-
-
-
-
-
-
-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)
+Live site: [mail.newszxcn.com](https://mail.newszxcn.com)
## Features
-- **Webmail client**: multiple mailbox switching, folders, reading and composing messages, drafts, scheduled sending, attachments, search, labels, stars, move/delete, read/unread status.
-- **Mailbox enhancements**: contacts, signatures, inbox rules, sender blacklist, mail statistics, archive read messages, empty Trash/Spam.
-- **Multi-domain / multi-mailbox**: domain management, DKIM key generation, DNS record display and checks, mailbox accounts, alias forwarding, catch-all toggle.
-- **Accounts and permissions**: login/registration, session management, TOTP two-factor authentication, Cloudflare Turnstile, user self-service mailbox requests, permission groups/RBAC.
-- **Admin panel**: overview checklist, user/permission group/domain/mailbox/alias/all-message management, system settings, mail templates, SMTP testing.
-- **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.
+- Webmail: inbox, compose, attachments, drafts, search, stars, labels, read/unread
+- Multi-mailbox and multi-domain management, DKIM, DNS checks, forwarding
+- Account management, mailbox quotas, permission quotas, registration, mailbox requests
+- Admin console, all mail, send queue, system settings
+- Postfix, Dovecot, Rspamd, SQLite, Docker single-container deployment
-## UI Preview
+## Screenshots
-| Webmail reading and list | Compose · rich-text toolbar |
-| --- | --- |
-|  |  |
-| 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 |
-|  |  |
-| 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. |
+Replace these images when needed:
-## Repository Structure
+- `docs/screenshots/mail-preview.png`
+- `docs/screenshots/compose-preview.png`
+- `docs/screenshots/admin-preview.png`
+- `docs/screenshots/client-preview.png`
-```text
-.
-├── 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
-```
+## Stack
-## Requirements
+- Backend: Go
+- Frontend: React + TypeScript + shadcn/ui
+- Database: SQLite
+- Mail stack: Postfix + Dovecot + Rspamd
+- Deployment: Docker / Docker Compose
-### Development
-
-- 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:
+## Quick Deploy
```bash
cd deploy
cp .env.example .env
-# Edit .env: domain, public URL, admin email, admin password, etc.
-docker compose pull
-docker compose up -d
+# Edit domain, public URL, admin email, and admin password
+docker compose up -d --build
```
-Common commands:
+Public mail delivery requires MX, SPF, DKIM, DMARC, and open mail ports.
-```bash
-# View logs
-docker compose logs -f lanqin-email
+## Note
-# Pull the latest image and restart
-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.
+This is the NewSzxcn maintained version. Future changes are based on this repository.
## License
[MIT](./LICENSE)
-
-## Star History
-
-
-
-
-
-
-
-
-
-Friends: [LINUX DO](https://linux.do/) — a new ideal community
diff --git a/README.md b/README.md
index 942e96d..cf364a1 100644
--- a/README.md
+++ b/README.md
@@ -1,245 +1,68 @@
-# LanQin Email
+# NewSzxcn-Email
-[](./README.en.md)
-[](./README.md)
+NewSzxcn-Email 是一个可自建、可管理、开箱即用的开源邮箱系统。
-
-
-
-
-
-
-
-
+在线地址:[mail.newszxcn.com](https://mail.newszxcn.com)
-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 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
-| --- | --- |
-|  |  |
-| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
-| 管理后台 · 系统概览 | 第三方客户端配置 |
-|  |  |
-| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
+## 技术栈
-## 目录结构
+- 后端:Go
+- 前端:React + TypeScript + shadcn/ui
+- 数据库:SQLite
+- 邮件服务:Postfix + Dovecot + Rspamd
+- 部署:Docker / Docker Compose
-```text
-.
-├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑
-├── apps/web # React/Vite Webmail 与管理后台
-├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
-└── .github/workflows # Docker 镜像发布流水线
+## 快速部署
+
+```bash
+cd deploy
+cp .env.example .env
+# 修改域名、访问地址、管理员邮箱、管理员密码
+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
cd apps/api
-go mod download
-go test ./...
go run ./cmd/server
```
-前端(新终端):
-
```bash
cd apps/web
-corepack enable
-corepack prepare pnpm@10.28.2 --activate
pnpm install
pnpm run dev
```
-访问:
+## 说明
-- Web:`http://localhost:5173`
-- 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` 去重。
+这是 NewSzxcn 自用维护版本,后续功能和界面修改都以本仓库为准。
## License
[MIT](./LICENSE)
-
-
-## Star 趋势
-
-
-
-
-
-
-
-
-
-友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 942e96d..cf364a1 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,245 +1,68 @@
-# LanQin Email
+# NewSzxcn-Email
-[](./README.en.md)
-[](./README.md)
+NewSzxcn-Email 是一个可自建、可管理、开箱即用的开源邮箱系统。
-
-
-
-
-
-
-
-
+在线地址:[mail.newszxcn.com](https://mail.newszxcn.com)
-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 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
-| --- | --- |
-|  |  |
-| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
-| 管理后台 · 系统概览 | 第三方客户端配置 |
-|  |  |
-| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
+## 技术栈
-## 目录结构
+- 后端:Go
+- 前端:React + TypeScript + shadcn/ui
+- 数据库:SQLite
+- 邮件服务:Postfix + Dovecot + Rspamd
+- 部署:Docker / Docker Compose
-```text
-.
-├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑
-├── apps/web # React/Vite Webmail 与管理后台
-├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
-└── .github/workflows # Docker 镜像发布流水线
+## 快速部署
+
+```bash
+cd deploy
+cp .env.example .env
+# 修改域名、访问地址、管理员邮箱、管理员密码
+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
cd apps/api
-go mod download
-go test ./...
go run ./cmd/server
```
-前端(新终端):
-
```bash
cd apps/web
-corepack enable
-corepack prepare pnpm@10.28.2 --activate
pnpm install
pnpm run dev
```
-访问:
+## 说明
-- Web:`http://localhost:5173`
-- 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` 去重。
+这是 NewSzxcn 自用维护版本,后续功能和界面修改都以本仓库为准。
## License
[MIT](./LICENSE)
-
-
-## Star 趋势
-
-
-
-
-
-
-
-
-
-友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go
index 2845a17..5249de2 100644
--- a/apps/api/internal/app/admin_handlers.go
+++ b/apps/api/internal/app/admin_handlers.go
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(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
- 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`)
if err != nil {
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() {
var item AdminUser
var disabled, twoFactorEnabled int
+ var mailboxLimitOverride sql.NullInt64
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")
return
}
item.Disabled = intBool(disabled)
item.TwoFactorEnabled = intBool(twoFactorEnabled)
+ item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
item.CreatedAt = parseTime(created)
item.Mailboxes = splitCSV(mailboxCSV)
items = append(items, item)
@@ -97,6 +99,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
+ MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -125,6 +128,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
+ mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
+ if err != nil {
+ badRequest(w, err)
+ return
+ }
+ if role == "admin" {
+ mailboxLimitOverride = nil
+ }
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
return
@@ -142,8 +153,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
- if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now); err != nil {
+ 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), nullableInt(mailboxLimitOverride), now, now); err != nil {
badRequest(w, err)
return
}
@@ -174,6 +185,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
+ MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -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"))
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 {
badRequest(w, err)
return
@@ -254,8 +277,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
- if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
- displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
+ if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
+ displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update user")
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) {
- 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
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 disabled, twoFactorEnabled int
+ var mailboxLimitOverride sql.NullInt64
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
}
item.Disabled = intBool(disabled)
item.TwoFactorEnabled = intBool(twoFactorEnabled)
+ item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
item.CreatedAt = parseTime(created)
item.Mailboxes = splitCSV(mailboxCSV)
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go
index 3246318..e2b5b23 100644
--- a/apps/api/internal/app/app.go
+++ b/apps/api/internal/app/app.go
@@ -129,6 +129,7 @@ func (a *App) migrate(ctx context.Context) error {
password_hash TEXT NOT NULL,
two_factor_secret TEXT NOT NULL DEFAULT '',
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
+ mailbox_limit_override INTEGER,
disabled INTEGER NOT NULL DEFAULT 0,
created_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,
description TEXT NOT NULL DEFAULT '',
permissions_json TEXT NOT NULL DEFAULT '[]',
- limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
+ limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"maxMailboxCount":9,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
system INTEGER NOT NULL DEFAULT 0,
created_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 {
return err
}
+ if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
+ return err
+ }
if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err
}
@@ -852,7 +856,7 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
if hasLimits {
return nil
}
- _, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
+ _, 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
}
@@ -1050,6 +1054,36 @@ func (a *App) migrateUsersForTwoFactor(ctx context.Context) error {
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, ¬null, &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 {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
@@ -1228,7 +1262,7 @@ func (a *App) seed(ctx context.Context) error {
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)
- 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
}
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
}
now := a.now().UTC()
- subject := "欢迎使用 LanQin Email"
+ subject := "欢迎使用 NewSzxcn 邮箱"
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
bodyHTML := "
你的自建邮箱 Webmail 已经初始化完成。
请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。
"
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")),
Subject: subject,
From: "system@lanqin.local",
- FromName: "LanQin Email",
+ FromName: "NewSzxcn 邮箱",
To: []string{a.cfg.AdminEmail},
SentAt: now,
ReceivedAt: now,
diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go
index 67e03ec..db15d07 100644
--- a/apps/api/internal/app/app_test.go
+++ b/apps/api/internal/app/app_test.go
@@ -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 {
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)
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 {
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)
}
@@ -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 {
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) {
@@ -3956,11 +3981,11 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
"name": "Mailbox Viewers",
"description": "Can view mailboxes only",
"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 {
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)
}
if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) {
diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go
index 2c687ee..2f81449 100644
--- a/apps/api/internal/app/permissions.go
+++ b/apps/api/internal/app/permissions.go
@@ -138,6 +138,7 @@ type PermissionGroup struct {
type PermissionLimits struct {
MaxAttachmentMB int `json:"maxAttachmentMb"`
+ MaxMailboxCount int `json:"maxMailboxCount"`
SMTPDailyLimit int `json:"smtpDailyLimit"`
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
IMAPMinuteLimit int `json:"imapMinuteLimit"`
@@ -147,6 +148,7 @@ type PermissionLimits struct {
func defaultPermissionLimits() PermissionLimits {
return PermissionLimits{
MaxAttachmentMB: 25,
+ MaxMailboxCount: 9,
SMTPDailyLimit: 200,
SMTPMinuteLimit: 20,
IMAPMinuteLimit: 200,
@@ -158,6 +160,9 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
if limits.MaxAttachmentMB < 0 {
return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative")
}
+ if limits.MaxMailboxCount < 0 {
+ return PermissionLimits{}, errors.New("maxMailboxCount cannot be negative")
+ }
if limits.SMTPDailyLimit < 0 {
return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative")
}
@@ -173,6 +178,17 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
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 {
limits := defaultPermissionLimits()
if strings.TrimSpace(value) == "" {
@@ -198,6 +214,7 @@ func encodePermissionLimits(limits PermissionLimits) string {
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
return PermissionLimits{
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
+ MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount),
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
@@ -221,6 +238,7 @@ func minimalLimits() PermissionLimits {
// when no group has a limit set for a given field.
return PermissionLimits{
MaxAttachmentMB: 1,
+ MaxMailboxCount: 1,
SMTPDailyLimit: 1,
SMTPMinuteLimit: 1,
IMAPMinuteLimit: 1,
@@ -236,6 +254,7 @@ func actorCanGrantLimits(actor *User, limits PermissionLimits) bool {
return true
}
return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) &&
+ canGrantLimitValue(actor.Limits.MaxMailboxCount, limits.MaxMailboxCount) &&
canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) &&
canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) &&
canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) &&
@@ -292,20 +311,20 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"},
{Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"},
{Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"},
- {Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱账号。", Category: "个人中心"},
+ {Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"},
- {Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
+ {Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
- {Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
- {Key: PermissionUsersCreate, Label: "创建用户", Description: "创建普通用户并分配权限组。", Category: "用户"},
- {Key: PermissionUsersUpdate, Label: "编辑用户", Description: "修改用户显示名称、状态和权限组。", Category: "用户"},
- {Key: PermissionUsersDelete, Label: "删除用户", Description: "删除非受保护用户。", Category: "用户"},
- {Key: PermissionUsersResetPassword, Label: "重置用户密码", Description: "为用户重置登录密码。", Category: "用户"},
+ {Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
+ {Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
+ {Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"},
+ {Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
+ {Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
- {Key: PermissionGroupsView, Label: "查看权限组", Description: "查看权限组、权限目录和使用人数。", Category: "权限组"},
- {Key: PermissionGroupsCreate, Label: "创建权限组", Description: "创建自定义权限组。", Category: "权限组"},
- {Key: PermissionGroupsUpdate, Label: "编辑权限组", Description: "修改自定义权限组名称、说明和权限。", Category: "权限组"},
- {Key: PermissionGroupsDelete, Label: "删除权限组", Description: "删除未被用户使用的自定义权限组。", Category: "权限组"},
+ {Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"},
+ {Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"},
+ {Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"},
+ {Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"},
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
@@ -315,15 +334,15 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"},
{Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"},
- {Key: PermissionMailboxesView, Label: "查看邮箱账号", Description: "查看邮箱账号列表和归属用户。", Category: "邮箱账号"},
- {Key: PermissionMailboxesCreate, Label: "创建邮箱账号", Description: "创建邮箱账号并准备归属用户。", Category: "邮箱账号"},
- {Key: PermissionMailboxesUpdate, Label: "编辑邮箱账号", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱账号"},
- {Key: PermissionMailboxesDelete, Label: "删除邮箱账号", Description: "删除邮箱账号及关联邮件文件。", Category: "邮箱账号"},
+ {Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"},
+ {Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"},
+ {Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"},
+ {Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"},
- {Key: PermissionAliasesView, Label: "查看别名转发", Description: "查看别名转发规则。", Category: "别名转发"},
- {Key: PermissionAliasesCreate, Label: "创建别名转发", Description: "创建新的别名转发。", Category: "别名转发"},
- {Key: PermissionAliasesUpdate, Label: "编辑别名转发", Description: "修改别名转发来源、目标和启用状态。", Category: "别名转发"},
- {Key: PermissionAliasesDelete, Label: "删除别名转发", Description: "删除别名转发规则。", Category: "别名转发"},
+ {Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"},
+ {Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"},
+ {Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"},
+ {Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"},
{Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"},
{Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"},
@@ -435,8 +454,8 @@ func defaultPermissionGroups() []PermissionGroup {
return []PermissionGroup{
{
ID: PermissionGroupSuperAdmin,
- Name: "超级管理员",
- Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
+ Name: "管理员",
+ Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。",
Permissions: allPermissionKeys(),
Limits: PermissionLimits{},
System: true,
@@ -611,6 +630,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
}
u.Permissions = permissions
u.Limits = limits
+ if u.Role != "admin" && u.MailboxLimitOverride != nil {
+ u.Limits.MaxMailboxCount = *u.MailboxLimitOverride
+ }
u.PermissionGroupIDs = groupIDs
u.PermissionGroups = groups
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) {
if role == "admin" {
- group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
+ group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "管理员"}
return []string{group.ID}, []PermissionGroupSummary{group}, nil
}
ids := []string{PermissionGroupRegular}
diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go
index 2094446..affdec6 100644
--- a/apps/api/internal/app/personal_handlers.go
+++ b/apps/api/internal/app/personal_handlers.go
@@ -88,6 +88,17 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
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
if err := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND disabled=0`, user.ID).Scan(&passwordHash); err != nil {
diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go
index c826ac1..632ca3e 100644
--- a/apps/api/internal/app/router_auth.go
+++ b/apps/api/internal/app/router_auth.go
@@ -270,17 +270,19 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
if err != nil || cookie.Value == "" {
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
WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
var u User
var disabled, twoFactorEnabled int
+ var mailboxLimitOverride sql.NullInt64
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
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
+ u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if u.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")
}
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
WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now)
var tokenID, scopesJSON string
var u User
var disabled, twoFactorEnabled int
+ var mailboxLimitOverride sql.NullInt64
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
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
+ u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if u.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) {
- 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 passwordHash string
var disabled, twoFactorEnabled int
+ var mailboxLimitOverride sql.NullInt64
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) {
return nil, "", errNotFound
}
@@ -346,6 +351,7 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
+ u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
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) {
- 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 disabled, twoFactorEnabled int
+ var mailboxLimitOverride sql.NullInt64
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) {
return nil, errNotFound
}
@@ -366,6 +373,7 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
+ u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, err
diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go
index 6b50222..f18d1ee 100644
--- a/apps/api/internal/app/settings_handlers.go
+++ b/apps/api/internal/app/settings_handlers.go
@@ -251,7 +251,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
domain = "lanqin.local"
}
now := a.now().UTC()
- subject := "LanQin Email SMTP 测试"
+ subject := "NewSzxcn 邮箱 SMTP 测试"
bodyText := "这是一封 SMTP 测试邮件。"
bodyHTML := "这是一封 SMTP 测试邮件。
"
if tpl, err := a.mailTemplate(r.Context(), smtpTestTemplateKey); err == nil {
diff --git a/apps/api/internal/app/status_webhook.go b/apps/api/internal/app/status_webhook.go
index 562eec1..db670b9 100644
--- a/apps/api/internal/app/status_webhook.go
+++ b/apps/api/internal/app/status_webhook.go
@@ -112,7 +112,7 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
return err
}
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-Timestamp", timestamp)
req.Header.Set("X-LanQin-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
diff --git a/apps/api/internal/app/template_handlers.go b/apps/api/internal/app/template_handlers.go
index 2e79526..f129b96 100644
--- a/apps/api/internal/app/template_handlers.go
+++ b/apps/api/internal/app/template_handlers.go
@@ -43,7 +43,7 @@ func defaultMailTemplates() []MailTemplate {
{
Key: "welcome",
Name: "欢迎邮件",
- Subject: "欢迎使用 LanQin Email",
+ Subject: "欢迎使用 NewSzxcn 邮箱",
BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。",
BodyHTML: "你的自建邮箱 Webmail 已经初始化完成。
请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。
",
UpdatedAt: now,
@@ -51,7 +51,7 @@ func defaultMailTemplates() []MailTemplate {
{
Key: smtpTestTemplateKey,
Name: "SMTP 测试",
- Subject: "LanQin Email SMTP 测试",
+ Subject: "NewSzxcn 邮箱 SMTP 测试",
BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}",
BodyHTML: "这是一封 SMTP 测试邮件。
发件人:{{from}}
收件人:{{to}}
时间:{{time}}
主机:{{publicHostname}}
",
UpdatedAt: now,
diff --git a/apps/api/internal/app/two_factor.go b/apps/api/internal/app/two_factor.go
index 1d832d5..bd109b8 100644
--- a/apps/api/internal/app/two_factor.go
+++ b/apps/api/internal/app/two_factor.go
@@ -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) {
- 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 disabled, twoFactorEnabled int
+ var mailboxLimitOverride sql.NullInt64
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) {
return nil, "", errNotFound
}
@@ -134,6 +135,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
+ u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, "", err
@@ -172,7 +174,7 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
}
respondJSON(w, http.StatusOK, map[string]any{
"secret": secret,
- "otpauthUrl": totpProvisioningURI("LanQin Email", current.Email, secret),
+ "otpauthUrl": totpProvisioningURI("NewSzxcn 邮箱", current.Email, secret),
})
}
diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go
index 9d85883..f77b990 100644
--- a/apps/api/internal/app/types.go
+++ b/apps/api/internal/app/types.go
@@ -10,6 +10,7 @@ type User struct {
Disabled bool `json:"disabled"`
Protected bool `json:"protected"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
+ MailboxLimitOverride *int `json:"mailboxLimitOverride,omitempty"`
Permissions []string `json:"permissions"`
Limits PermissionLimits `json:"limits"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
diff --git a/apps/api/internal/app/util.go b/apps/api/internal/app/util.go
index 9f19971..038ccd1 100644
--- a/apps/api/internal/app/util.go
+++ b/apps/api/internal/app/util.go
@@ -215,6 +215,21 @@ func nullableString(v string) any {
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 {
t, _ := time.Parse(time.RFC3339Nano, v)
return t
diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx
index 29fb9f8..cf7d37e 100644
--- a/apps/web/src/components/protected-layout.tsx
+++ b/apps/web/src/components/protected-layout.tsx
@@ -1,6 +1,6 @@
import * as React from "react"
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 { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
@@ -27,13 +27,14 @@ import {
} from "@/components/ui/sidebar"
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
- { key: "overview", label: "概览", icon: , permissions: ["admin.overview.view"] },
- { key: "users", label: "用户", icon: , permissions: ["admin.users.view"] },
- { key: "permissionGroups", label: "权限组", icon: , permissions: ["admin.permission_groups.view"] },
- { key: "domains", label: "域名", icon: , permissions: ["admin.domains.view", "admin.dns.view"] },
- { key: "mailboxes", label: "邮箱账号", icon: , permissions: ["admin.mailboxes.view"] },
- { key: "aliases", label: "别名转发", icon: , permissions: ["admin.aliases.view"] },
+ { key: "overview", label: "数据总览", icon: , permissions: ["admin.overview.view"] },
+ { key: "users", label: "账号管理", icon: , permissions: ["admin.users.view"] },
+ { key: "permissionGroups", label: "权限配额", icon: , permissions: ["admin.permission_groups.view"] },
+ { key: "domains", label: "域名管理", icon: , permissions: ["admin.domains.view", "admin.dns.view"] },
+ { key: "mailboxes", label: "邮箱管理", icon: , permissions: ["admin.mailboxes.view"] },
+ { key: "aliases", label: "邮件转发", icon: , permissions: ["admin.aliases.view"] },
{ key: "messages", label: "全部邮件", icon: , permissions: ["admin.messages.view"] },
+ { key: "sendAudit", label: "发送队列", icon: , permissions: ["admin.messages.view"] },
{ key: "settings", label: "系统设置", icon: , permissions: ["admin.settings.view", "admin.templates.view"] },
]
@@ -64,16 +65,16 @@ function ProtectedContent() {
return (
-
+
-
-
+
+
- LanQin Email
+ NewSzxcn 邮箱
@@ -106,7 +107,7 @@ function ProtectedContent() {
{user.email}
- {user.role === "admin" ? "超级管理员" : "普通用户"}
+ {user.role === "admin" ? "管理员" : "普通用户"}
@@ -125,7 +126,7 @@ function ProtectedContent() {
- {isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
+ {isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "NewSzxcn 邮箱"}
diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts
index 192bc5d..a087dca 100644
--- a/apps/web/src/lib/api-types.ts
+++ b/apps/web/src/lib/api-types.ts
@@ -47,10 +47,10 @@ export type PermissionKey =
| "admin.templates.update"
| "admin.templates.reset"
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
-export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
+export type PermissionLimits = { maxAttachmentMb: number; maxMailboxCount: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
-export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
+export type 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 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 }
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 336ee6a..c11a8a2 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -125,8 +125,8 @@ export const api = {
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
- createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
- updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
+ createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
+ updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request>("/api/admin/domains"),
diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx
index f37664c..508147c 100644
--- a/apps/web/src/pages/admin.tsx
+++ b/apps/web/src/pages/admin.tsx
@@ -2,7 +2,7 @@ import * as React from "react"
import DOMPurify from "dompurify"
import { useSearchParams } from "react-router-dom"
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import { ArrowRight, BookOpen, CheckCircle2, 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 { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
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 PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
-const sectionLabels: Record = {
- overview: "概览",
- users: "用户",
- permissionGroups: "权限组",
- domains: "域名",
- mailboxes: "邮箱账号",
- aliases: "别名转发",
- messages: "全部邮件",
- sendAudit: "发送审计",
- settings: "系统设置",
+const sectionMeta: Record = {
+ overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
+ users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
+ permissionGroups: { label: "权限配额", frontLabel: "账号配额", description: "配置前台菜单权限、发信频率、附件和邮箱创建额度。" },
+ domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
+ mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
+ aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
+ messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
+ sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
+ settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
}
+const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record
const sectionKeys = Object.keys(sectionLabels) as Section[]
const sectionPermissions: Record = {
overview: ["admin.overview.view"],
@@ -51,11 +52,12 @@ const sectionPermissions: Record = {
sendAudit: ["admin.messages.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 projectTag = import.meta.env.VITE_APP_VERSION || ""
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
-const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
+const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
+const defaultMailboxLimitOverride = 9
export function AdminPage() {
const me = useMe()
@@ -90,17 +92,15 @@ export function AdminPage() {
return (
-
-
-
{sectionLabels[section]}
-
+
+
{section === "overview" && canOverview && (
-
-
} label="用户" value={overview.data?.users || 0} />
-
} label="域名" value={overview.data?.domains || 0} />
-
} label="邮箱账号" value={overview.data?.mailboxes || 0} />
-
} label="存储" value={formatBytes(overview.data?.storageBytes || 0)} />
+
+ } label="账号" value={overview.data?.users || 0} />
+ } label="邮件域名" value={overview.data?.domains || 0} />
+ } label="邮箱" value={overview.data?.mailboxes || 0} />
+ } label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} />
)}
@@ -117,6 +117,27 @@ export function AdminPage() {
)
}
+
+function AdminPageHeader({ section }: { section: Section }) {
+ const meta = sectionMeta[section]
+ return (
+
+
+
+
+ 后台管理
+
+ 前台:{meta.frontLabel}
+
+
{meta.label}
+
{meta.description}
+
+
NewSzxcn
+
+
+ )
+}
+
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section))
return (
@@ -125,9 +146,9 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
系统状态
-
+
-
+
@@ -161,7 +182,7 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
-
+
@@ -177,7 +198,7 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
return [
{ key: "domain", title: "添加邮件域名", detail: hasDomain ? `${domains.length} 个域名已添加` : "先添加 example.com 这样的邮件域名", done: hasDomain, section: "domains" as Section },
{ key: "dns", title: "完成 DNS 检测", detail: dnsReady ? "至少一个域名 DNS 正常" : "配置 MX、SPF、DKIM、DMARC 后执行检测", done: dnsReady, section: "domains" as Section },
- { key: "mailbox", title: "创建邮箱账号", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给超级管理员或普通用户创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
+ { key: "mailbox", title: "创建邮箱", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给管理员或普通账号创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
{ key: "smtp", title: "确认发信链路", detail: settings?.smtpHost ? `内置 Postfix:${settings.smtpHost}:${settings.smtpPort}` : "默认使用内置 Postfix", done: true, section: "settings" as Section },
{ key: "mail", title: "完成收发测试", detail: hasMail ? `${overview?.messages || 0} 封邮件已入库` : "发送或接收一封测试邮件", done: hasMail, section: "messages" as Section },
]
@@ -205,12 +226,12 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
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 (
- 用户管理
+ 账号管理
{canCreate && }
@@ -218,13 +239,13 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
- setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} />
+ setPendingConfirm({ title: "删除账号?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
@@ -259,7 +280,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
- 用户身份权限组邮箱状态创建时间
+ 账号身份权限配额邮箱状态创建
{filteredUsers.map((user) => (
@@ -269,16 +290,16 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
-
+
{user.disabled ? "停用" : "正常"}
{new Date(user.createdAt).toLocaleDateString()}
- setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} />
+ setPendingConfirm({ title: "删除账号?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
))}
- {filteredUsers.length === 0 && }
+ {filteredUsers.length === 0 && }
{ if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
@@ -301,7 +322,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
onSuccess: () => {
setPendingConfirm(null)
invalidateAdmin(qc)
- toast({ title: "权限组已删除" })
+ toast({ title: "权限配额已删除" })
},
onError: (e) => toast({ title: "删除失败", description: e.message }),
})
@@ -316,14 +337,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
-
权限组管理
+
权限配额
{canCreate &&
}
- setQuery(event.target.value)} placeholder="搜索权限组、说明或权限键" className="pl-9" />
+ setQuery(event.target.value)} placeholder="搜索权限配额、说明或权限键" className="pl-9" />
{filtered.map((group) => (
@@ -341,14 +362,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
{(canUpdate || canDelete) &&
- setEditing(group)}>编辑权限组
+ setEditing(group)}>编辑权限配额
setPendingConfirm({ title: "删除权限组?", description: `${group.name} 删除后不能再分配给用户。`, confirmText: "删除权限组", onConfirm: () => remove.mutate(group.id) })}
+ onSelect={() => setPendingConfirm({ title: "删除权限配额?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配额", onConfirm: () => remove.mutate(group.id) })}
>
- 删除权限组
+ 删除权限配额
}
@@ -358,7 +379,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
))}
- {filtered.length === 0 && }
+ {filtered.length === 0 && }
{editing && { if (!open) setEditing(null) }} />}
{ if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
@@ -395,20 +416,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
onSuccess: () => {
invalidateAdmin(qc)
setDialogOpen(false)
- toast({ title: group ? "权限组已更新" : "权限组已创建" })
+ toast({ title: group ? "权限配额已更新" : "权限配额已创建" })
},
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
})
const trigger = group ? null : (
-
+
)
return (
@@ -677,13 +703,13 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
const canCreate = hasPermission(user, "admin.aliases.create")
const canUpdate = hasPermission(user, "admin.aliases.update")
const canDelete = hasPermission(user, "admin.aliases.delete")
- const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
- const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
+ const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "转发已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
+ const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "转发已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
return (
- 别名/转发管理
+ 邮件转发
{canCreate && }
@@ -696,7 +722,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
{alias.source}
{alias.destination}
- 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} />
+ 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} />
{alias.enabled ? "启用" : "停用"}
@@ -715,13 +741,13 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
{alias.destination}
{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}
{alias.enabled ? "启用" : "停用"}
-
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} />
+
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} />
))}
- {aliases.length === 0 && }
+ {aliases.length === 0 && }
{ if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
@@ -880,7 +906,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
- 发送审计
+ 发送队列
@@ -955,7 +981,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
{audit.isLoading && }
- {!audit.isLoading && items.length === 0 && }
+ {!audit.isLoading && items.length === 0 && }
{!audit.isLoading && audit.hasNextPage && (