From 9b59629993c0929caa9de9b056600f9b1a91645c Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:07:16 +0800 Subject: [PATCH] feat: align NewSzxcn admin experience --- README.en.md | 247 ++------------ README.md | 251 +++----------- README.zh-CN.md | 251 +++----------- apps/api/internal/app/admin_handlers.go | 45 ++- apps/api/internal/app/app.go | 44 ++- apps/api/internal/app/app_test.go | 33 +- apps/api/internal/app/permissions.go | 66 ++-- apps/api/internal/app/personal_handlers.go | 11 + apps/api/internal/app/router_auth.go | 24 +- apps/api/internal/app/settings_handlers.go | 2 +- apps/api/internal/app/status_webhook.go | 2 +- apps/api/internal/app/template_handlers.go | 4 +- apps/api/internal/app/two_factor.go | 8 +- apps/api/internal/app/types.go | 1 + apps/api/internal/app/util.go | 15 + apps/web/src/components/protected-layout.tsx | 27 +- apps/web/src/lib/api-types.ts | 4 +- apps/web/src/lib/api.ts | 4 +- apps/web/src/pages/admin.tsx | 329 ++++++++++++++----- apps/web/src/pages/login.tsx | 3 +- apps/web/src/pages/mail.tsx | 27 +- apps/web/src/pages/profile.tsx | 11 +- apps/web/src/pages/register.tsx | 2 +- 23 files changed, 583 insertions(+), 828 deletions(-) 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 -[![English](https://img.shields.io/badge/English-README-blue)](./README.en.md) -[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.md) +NewSzxcn-Email is a self-hosted, manageable, ready-to-run open-source email system. -![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go) -![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) +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 | -| --- | --- | -| ![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. | +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 - - - - - - Star History Chart - - - -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 -[![English](https://img.shields.io/badge/English-README-blue)](./README.en.md) -[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.md) +NewSzxcn-Email 是一个可自建、可管理、开箱即用的开源邮箱系统。 -![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go) -![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) +在线地址:[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 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 | -| --- | --- | -| ![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、邮件同步与业务逻辑 -├── 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 趋势 - - - - - - Star History Chart - - - -友情链接:[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 -[![English](https://img.shields.io/badge/English-README-blue)](./README.en.md) -[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.md) +NewSzxcn-Email 是一个可自建、可管理、开箱即用的开源邮箱系统。 -![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go) -![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) +在线地址:[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 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 | -| --- | --- | -| ![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、邮件同步与业务逻辑 -├── 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 趋势 - - - - - - Star History Chart - - - -友情链接:[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
- setQuery(event.target.value)} placeholder="搜索用户、邮箱、显示名称" className="pl-9" /> + setQuery(event.target.value)} placeholder="搜索账号、邮箱、显示名称" className="pl-9" />
@@ -245,7 +266,7 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
{user.displayName}
{user.email}
- 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 ( {trigger} - {group ? "编辑权限组" : "创建权限组"} + {group ? "编辑权限配额" : "创建权限配额"}
{ event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
@@ -481,6 +502,10 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o 填 0 表示不限制
+
+ + update("maxMailboxCount", event.target.value)} /> +
update("maxAttachmentMb", event.target.value)} /> @@ -525,6 +550,7 @@ function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) { return (
附件 {limitText(value.maxAttachmentMb, "MB")} + 邮箱 {limitText(value.maxMailboxCount, "个")} SMTP 每日 {limitText(value.smtpDailyLimit, "封")} SMTP 每分钟 {limitText(value.smtpMinuteLimit, "封")} IMAP 每分钟 {limitText(value.imapMinuteLimit, "次")} @@ -582,7 +608,7 @@ function DomainsSection({ domains }: { domains: Domain[] }) { {domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus} {canViewDNS && } {canUpdate && } - {canDelete && } + {canDelete && }
))} @@ -621,7 +647,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
- 邮箱账号管理 + 邮箱管理 {canCreate && }
@@ -646,7 +672,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
- 地址归属用户名称配额状态 + 地址归属账号名称配额状态 {mailboxes.map((mailbox) => ( @@ -661,7 +687,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
- {mailboxes.length === 0 && } + {mailboxes.length === 0 && } { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> @@ -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 && (
+ + +
+
+ + setMailboxQuery(event.target.value)} + onKeyDown={(event) => event.stopPropagation()} + placeholder="搜索邮箱..." + className="h-8 rounded-md bg-background pl-8 pr-2 text-[13px] shadow-none" + /> +
+
+ {filteredMailboxes.map((mailbox) => ( + 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")} + > + + {mailbox} + + ))} + {sortedMailboxes.length === 0 && 暂无创建邮箱} + {sortedMailboxes.length > 0 && filteredMailboxes.length === 0 && 没有匹配邮箱} +
+ + +
+ {quota} ) } @@ -1721,7 +1815,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission } return (
- +
{groups.map((group) => { const checked = value.includes(group.id) @@ -1736,7 +1830,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission ) })}
- {groups.length === 0 && } + {groups.length === 0 && }
) } @@ -1744,7 +1838,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission function RoleBadge({ user }: { user: AdminUser }) { return (
- {user.role === "admin" ? "超级管理员" : "普通用户"} + {user.role === "admin" ? "管理员" : "普通用户"} {user.protected && 默认账号}
) @@ -1761,7 +1855,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe const canResetPassword = hasPermission(currentUser, "admin.users.reset_password") const update = useMutation({ 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 }), }) 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 - return <>{canUpdate && setEditOpen(true)}>编辑用户}{canResetPassword && setPasswordOpen(true)}>重置密码}{!user.protected && canUpdate && <> quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用用户" : "停用用户"} quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为超级管理员"}}{!user.protected && onDelete && <>删除用户}{canUpdate && }{canResetPassword && } + return <>{canUpdate && setEditOpen(true)}>编辑账号}{canResetPassword && setPasswordOpen(true)}>重置密码}{!user.protected && canUpdate && <> quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"} quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为管理员"}}{!user.protected && onDelete && <>删除账号}{canUpdate && }{canResetPassword && } } function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) { @@ -1785,23 +1879,32 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr const [status, setStatus] = React.useState("active") const [permissionGroupIds, setPermissionGroupIds] = React.useState([]) 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 : [] }), - onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "用户已创建" }) }, + mutationFn: (form: FormData) => api.createUser({ + email: String(form.get("email") || ""), + displayName: String(form.get("displayName") || ""), + password: String(form.get("password") || ""), + role, + disabled: status === "disabled", + 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 }), }) return ( - + - 创建用户 + 创建账号 { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}> - +
- setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "超级管理员"]]} /> + setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
+ {role === "user" && } {role === "user" && } @@ -1818,26 +1921,61 @@ function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: Mail function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) { if (!onToggle && !onDelete) return null - return {onToggle && {alias.enabled ? "停用" : "启用"}}{onToggle && onDelete && }{onDelete && 删除别名} + return {onToggle && {alias.enabled ? "停用" : "启用"}}{onToggle && onDelete && }{onDelete && 删除转发} } 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(assignableUserGroupIDs(user)) - React.useEffect(() => { setRole(user.role); setDisabled(user.disabled ? "disabled" : "active"); setPermissionGroupIds(assignableUserGroupIDs(user)) }, [user, open]) - const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "用户已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) }) - return 编辑用户
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
setRole(value as "admin" | "user")} items={[['user','普通用户'],['admin','超级管理员']]} disabled={user.protected} />
{role === "user" && !user.protected && }
+ 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(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 ( + + + 编辑账号 +
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> + + +
+ setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} /> + +
+ {role === "user" && !user.protected && } + {role === "user" && !user.protected && } + + +
+
+ ) } function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) { const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) }) - return 重置密码
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
+ return 重置密码
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}>
} 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) 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 }) }) - return 编辑邮箱
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> !u.disabled).map((u) => [u.id, u.email])} />
+ return 编辑邮箱
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> !u.disabled).map((u) => [u.id, u.email])} />
} 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("") React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId]) const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerEmail: String(form.get("ownerEmail") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) }) - return 创建邮箱账号
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
{ownerMode === "existing" ? !u.disabled).map((u) => [u.id, u.email])} /> : }
+ return 创建邮箱
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
{ownerMode === "existing" ? !u.disabled).map((u) => [u.id, u.email])} /> : }
} function CreateAliasDialog({ domains }: { domains: Domain[] }) { 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]) - 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 创建别名/转发
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
+ 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 创建邮件转发
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
} 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 & { label: string }) { return
} +function MailboxLimitField({ defaultValue }: { defaultValue: number }) { + return ( +
+ + +
普通用户默认 9 个,填 0 表示不限制。
+
+ ) +} +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
} function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return
} - - - diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx index c6de1d8..2fe9c7d 100644 --- a/apps/web/src/pages/login.tsx +++ b/apps/web/src/pages/login.tsx @@ -38,7 +38,7 @@ export function LoginPage() {
-

LanQin Email

+

NewSzxcn 邮箱

@@ -85,4 +85,3 @@ export function LoginPage() {
) } - diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 271ce59..b84e740 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -1076,7 +1076,7 @@ export function MailPage() { setDarkMode((value) => !value)} @@ -1084,7 +1084,7 @@ export function MailPage() { onLanguageChange={setLanguage} onSettings={openSettings} /> -
+
- {!sidebarCollapsed && !isAllMailboxSelected && ( - )} @@ -3117,7 +3128,7 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail align="start" className={cn( "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 && ( @@ -3622,7 +3633,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts, const allowed = maxAttachmentBytes > 0 ? nextFiles.filter((file) => file.size <= maxAttachmentBytes) : nextFiles const blockedCount = nextFiles.length - allowed.length if (blockedCount > 0) { - toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` }) + toast({ title: "附件超过配额上限", description: `当前单个附件上限 ${maxAttachmentText}` }) } if (allowed.length > 0) { setAttachmentsTouched(true) @@ -3633,7 +3644,7 @@ function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts, function attachmentsWithinLimit() { if (maxAttachmentBytes <= 0) return true if (files.every((file) => file.size <= maxAttachmentBytes)) return true - toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` }) + toast({ title: "附件超过配额上限", description: `当前单个附件上限 ${maxAttachmentText}` }) return false } @@ -4628,7 +4639,7 @@ function scheduleToIcs(schedule: ScheduleDraft) { const lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", - "PRODID:-//LanQin Email//Webmail//CN", + "PRODID:-//NewSzxcn Email//Webmail//CN", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "BEGIN:VEVENT", diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index d27e4f4..fae2d40 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -320,7 +320,7 @@ export function ProfilePage() { const sidebarContent = (