Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cbae154bf | |||
| bd6edf353d | |||
| 14ec1979b2 | |||
| 6fa98cec97 | |||
| 71746bdfba | |||
| f15a3d3222 | |||
| 7fc91f2ed9 | |||
| c482e5b896 | |||
| 07e62e9c2d | |||
| 5797b472d9 | |||
| 3caee11e38 | |||
| 2dd3647923 | |||
| ccb8ce01d9 | |||
| 4e3b69608f |
@@ -0,0 +1,7 @@
|
||||
- 修复完整备份上传 Google 云端硬盘失败:由小文件上传改为官方可恢复分块上传,支持大型邮箱备份。
|
||||
- 同一份本地加密备份只显示一次文件名,下方分别显示 Telegram 与 Google 云端硬盘的上传百分比、已上传大小和结果。
|
||||
- 手动发送改为后台任务,刷新或离开页面后上传仍会继续,返回备份页可继续查看进度。
|
||||
- Google 授权失效、空间不足、请求限流、Drive API 未启用及网络超时会显示对应中文处理建议。
|
||||
- 定时备份的云端推送失败也会直接显示具体原因,不再只提示查看服务器日志。
|
||||
|
||||
**完整更新日志**:[v1.2.34...v1.2.35](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.34...v1.2.35)
|
||||
@@ -0,0 +1,11 @@
|
||||
- Google 云端硬盘配置弹窗增加 Google Drive API 启用说明和官方控制台直达入口。
|
||||
- 明确提示必须在 OAuth 客户端所属的同一 Google Cloud 项目中启用 Drive API。
|
||||
- 补充启用 API 后重新连接 Google 账号的操作顺序,减少授权成功但无法上传的配置误区。
|
||||
- 写信与编辑草稿弹窗改为更紧凑的居中布局,重新整理字段、工具栏和发送操作区,完整保留附件、格式、签名、日程、预览和定时发送能力。
|
||||
- 修复超长授权码、链接和代码内容撑宽编辑器的问题,桌面端与手机端均会在正文范围内安全换行。
|
||||
- 启用浏览器原生拼写检查,并统一普通发送与定时发送的收件人校验。
|
||||
- 关闭写信窗口时立即保存最新正文与附件,保存失败会保留窗口并提示,避免等待自动保存期间丢失草稿。
|
||||
- 写信格式栏增加正文与标题 1/2/3 段落样式、实时字数统计和“更多格式”菜单,将完整格式能力稳定收纳在两行内。
|
||||
- 提高邮箱与管理后台次级文字的对比度,改善浅色与深色模式下的阅读清晰度。
|
||||
|
||||
**完整更新日志**:[v1.2.35...v1.2.36](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.35...v1.2.36)
|
||||
@@ -0,0 +1,4 @@
|
||||
- 写信页发送区改为 Gmail 风格拆分按钮,主按钮直接发送,右侧下拉菜单提供“定时发送”。
|
||||
- 移除容易被误认为日期选择器的独立日历方块,保留原有定时预设和自定义发送时间功能。
|
||||
|
||||
**完整更新日志**:[v1.2.36...v1.2.37](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.36...v1.2.37)
|
||||
@@ -0,0 +1,6 @@
|
||||
- 写信发送拆分按钮统一使用 Gmail 风格蓝色主操作,浅色与暗色模式都保持清晰白字及一致悬停状态。
|
||||
- 修复“定时发送”菜单按左侧展开导致右边框越过写信窗口的问题,菜单改为右对齐并增加边缘避让和垂直间距。
|
||||
- 将默认主操作按钮从通用强调色中拆分,暗色主题下后台、个人设置、规则、弹窗确认、登录注册等页面统一使用深灰底白字;勾选框、进度条、选中态等仍保留清晰的强调色。
|
||||
- 已保存的浅色或暗色主题现在会在应用启动时统一恢复,切换到后台、登录及其他页面后不再丢失主题状态。
|
||||
|
||||
**完整更新日志**:[v1.2.37...v1.2.38](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.37...v1.2.38)
|
||||
@@ -0,0 +1,4 @@
|
||||
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
|
||||
- 同步包含 v1.2.38 的暗色主按钮与 Gmail 蓝色发送按钮修复,更新完成后写信发送按钮在浅色和暗色模式下均为蓝底白字。
|
||||
|
||||
**完整更新日志**:[v1.2.38...v1.2.39](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.38...v1.2.39)
|
||||
@@ -0,0 +1,6 @@
|
||||
- 修复桌面端文件夹或标签较多时侧栏被内容撑出视口、底部项目无法访问的问题;账号、邮箱切换和写信入口保持固定,邮件导航区域独立滚动。
|
||||
- 写信发送按钮在浅色和暗色模式下统一为 Gmail 风格蓝底白字,定时发送菜单在桌面和手机端均不会越过写信窗口。
|
||||
- 暗色主题默认主操作按钮改为深灰底白字,并与勾选框、进度条、危险按钮等语义颜色分离;进入后台或登录页后也会保持已选择的主题。
|
||||
- 修复备份后台任务测试结束过早导致发布流程偶发失败的问题。
|
||||
|
||||
**完整更新日志**:[v1.2.39...v1.2.40](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.39...v1.2.40)
|
||||
@@ -0,0 +1,3 @@
|
||||
- 调整写信页定时发送菜单宽度,使其与“发送 + 下拉箭头”组合按钮左右边缘完全对齐,同时保留桌面和手机端边界避让。
|
||||
|
||||
**完整更新日志**:[v1.2.40...v1.2.41](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.40...v1.2.41)
|
||||
@@ -0,0 +1,4 @@
|
||||
- 将邮箱、个人设置和后台管理的当前选中项统一为清晰的淡蓝色,并补齐悬停、二级标签和邮件列表选中状态。
|
||||
- 优化后台数据总览:合并重复指标,改为四项核心数据、紧凑首次配置和统一系统状态布局,并确保公网地址完整显示。
|
||||
|
||||
**完整更新日志**:[v1.2.41...v1.2.42](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.41...v1.2.42)
|
||||
@@ -0,0 +1,6 @@
|
||||
- 将后台首页升级为紧凑的邮件系统仪表盘,优化核心指标、邮件运行概览、系统健康、域名状态和首次配置入口。
|
||||
- 优化后台、登录与邮箱界面细节,统一品牌图标、通知位置、选中状态、按钮边框与移动端布局,并修复全部邮件页面横向溢出。
|
||||
- 修复 Apple 等邮件的 GB2312、GBK、GB18030 标题乱码,改进账号切换后的邮箱文件夹显示与创建范围提示。
|
||||
- 精简系统设置,移除“关于”标签和相关内容;版本检查仍可通过左侧版本号入口使用。
|
||||
|
||||
**完整更新日志**:[v1.2.42...v1.2.43](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.42...v1.2.43)
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
NewSzxcn Email is a self-hosted mail server with a complete Webmail client and administration console. It bundles Go, React, Postfix, Dovecot, Rspamd, and SQLite into an all-in-one Docker deployment.
|
||||
|
||||
[Releases](https://github.com/zxyszx/NewSzxcn-Email/releases) · [Chinese README](README.md)
|
||||
[Releases](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [Chinese README](README.md)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -18,7 +18,7 @@ NewSzxcn Email is a self-hosted mail server with a complete Webmail client and a
|
||||
Debian and Ubuntu on `amd64` or `arm64` are supported.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
|
||||
@@ -55,7 +55,7 @@ Open TCP ports `25`, `80`, `443`, `465`, `587`, `993`, and `995` as needed. Publ
|
||||
## Manual source deployment
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/releases)
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml)
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
[邮箱后台配置指南](docs/GUIDE.md) · [版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) · [MIT License](LICENSE)
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -26,13 +21,13 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
### 管理面板
|
||||
@@ -194,7 +189,7 @@ sudo newszxcn-email uninstall
|
||||
需要自行控制 Compose 配置时:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
@@ -216,7 +211,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
- 后端:Go、Chi、SQLite
|
||||
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
||||
- 邮件:Postfix、Dovecot、Rspamd
|
||||
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
|
||||
- 部署:Docker、Docker Compose、GHCR
|
||||
|
||||
## 本地开发
|
||||
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||
|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
[版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -21,13 +21,13 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
||||
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
脚本会自动完成:
|
||||
@@ -123,7 +123,7 @@ sudo newszxcn-email uninstall
|
||||
需要自行控制 Compose 配置时:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
git clone https://gitea.xzys.me/szx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
@@ -145,7 +145,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
- 后端:Go、Chi、SQLite
|
||||
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
||||
- 邮件:Postfix、Dovecot、Rspamd
|
||||
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
|
||||
- 部署:Docker、Docker Compose、GHCR
|
||||
|
||||
## 本地开发
|
||||
|
||||
|
||||
@@ -24,23 +24,36 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
Messages int64 `json:"messages"`
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
TodaySent int64 `json:"todaySent"`
|
||||
TodayReceived int64 `json:"todayReceived"`
|
||||
SendDelivered int64 `json:"sendDelivered"`
|
||||
SendFailed int64 `json:"sendFailed"`
|
||||
QueueMessages int64 `json:"queueMessages"`
|
||||
}
|
||||
now := a.now().UTC()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
|
||||
queries := []struct {
|
||||
q string
|
||||
dest *int64
|
||||
args []any
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM users`, &out.Users},
|
||||
{`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers},
|
||||
{`SELECT COUNT(*) FROM domains`, &out.Domains},
|
||||
{`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes},
|
||||
{`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes},
|
||||
{`SELECT COUNT(*) FROM aliases`, &out.Aliases},
|
||||
{`SELECT COUNT(*) FROM messages`, &out.Messages},
|
||||
{`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages},
|
||||
{`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes},
|
||||
{q: `SELECT COUNT(*) FROM users`, dest: &out.Users},
|
||||
{q: `SELECT COUNT(*) FROM users WHERE disabled=0`, dest: &out.ActiveUsers},
|
||||
{q: `SELECT COUNT(*) FROM domains`, dest: &out.Domains},
|
||||
{q: `SELECT COUNT(*) FROM mailboxes`, dest: &out.Mailboxes},
|
||||
{q: `SELECT COUNT(*) FROM mailboxes WHERE status='active'`, dest: &out.ActiveMailboxes},
|
||||
{q: `SELECT COUNT(*) FROM aliases`, dest: &out.Aliases},
|
||||
{q: `SELECT COUNT(*) FROM messages`, dest: &out.Messages},
|
||||
{q: `SELECT COUNT(*) FROM messages WHERE is_read=0`, dest: &out.UnreadMessages},
|
||||
{q: `SELECT COALESCE(SUM(size_bytes),0) FROM messages`, dest: &out.StorageBytes},
|
||||
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role='sent' AND m.sent_at>=?`, dest: &out.TodaySent, args: []any{todayStart}},
|
||||
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role NOT IN ('sent','drafts') AND m.received_at>=?`, dest: &out.TodayReceived, args: []any{todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendDelivered, args: []any{sendQueueStatusDelivered, todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendFailed, args: []any{sendQueueStatusFailed, todayStart}},
|
||||
{q: `SELECT COUNT(*) FROM send_queue WHERE status IN (?,?)`, dest: &out.QueueMessages, args: []any{sendQueueStatusQueued, sendQueueStatusSending}},
|
||||
}
|
||||
for _, item := range queries {
|
||||
if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil {
|
||||
if err := a.db.QueryRowContext(r.Context(), item.q, item.args...).Scan(item.dest); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load overview")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ type App struct {
|
||||
telegramDeliveryMu sync.Mutex
|
||||
backupMu sync.Mutex
|
||||
backupJob *backupJob
|
||||
backupTransfers map[string]*backupTransfer
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -83,7 +84,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}}
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}, backupTransfers: map[string]*backupTransfer{}}
|
||||
a.externalIMAP = a
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -31,6 +30,7 @@ import (
|
||||
)
|
||||
|
||||
const backupTelegramLimit = 49 << 20
|
||||
const googleDriveUploadChunkSize = 8 << 20
|
||||
|
||||
type backupJob struct {
|
||||
Status string `json:"status"`
|
||||
@@ -45,6 +45,17 @@ type backupItem struct {
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
type backupTransfer struct {
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Uploaded int64 `json:"uploaded"`
|
||||
Total int64 `json:"total"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type backupListResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
TelegramSet bool `json:"telegramSet"`
|
||||
@@ -53,6 +64,7 @@ type backupListResponse struct {
|
||||
Items []backupItem `json:"items"`
|
||||
Schedule backupSchedule `json:"schedule"`
|
||||
GoogleDrive googleDriveStatus `json:"googleDrive"`
|
||||
Transfers []backupTransfer `json:"transfers"`
|
||||
}
|
||||
|
||||
type createBackupRequest struct {
|
||||
@@ -177,7 +189,13 @@ func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
copy := *job
|
||||
job = ©
|
||||
}
|
||||
transfers := make([]backupTransfer, 0, len(a.backupTransfers))
|
||||
for _, transfer := range a.backupTransfers {
|
||||
copy := *transfer
|
||||
transfers = append(transfers, copy)
|
||||
}
|
||||
a.backupMu.Unlock()
|
||||
sort.Slice(transfers, func(i, j int) bool { return transfers[i].StartedAt.After(transfers[j].StartedAt) })
|
||||
schedule, _ := a.loadBackupSchedule(r.Context())
|
||||
schedule.ServerIP = detectPublicServerIP(r.Context(), a.config().PublicHostname)
|
||||
telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule)
|
||||
@@ -185,7 +203,7 @@ func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
Enabled: a.backupAssetsAvailable(),
|
||||
TelegramSet: strings.TrimSpace(telegramToken) != "" && validTelegramPrivateChatID(telegramDestination),
|
||||
TelegramLimit: backupTelegramLimit, Job: job, Items: items, Schedule: schedule,
|
||||
GoogleDrive: a.loadGoogleDriveStatus(r.Context()),
|
||||
GoogleDrive: a.loadGoogleDriveStatus(r.Context()), Transfers: transfers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -250,16 +268,32 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
path, err := a.createDisasterBackup(ctx, password)
|
||||
password = ""
|
||||
var deliveryMessages []string
|
||||
if err == nil {
|
||||
var deliveryErrors []error
|
||||
if uploadGoogleDrive {
|
||||
a.queueBackupTransfer("googleDrive", path)
|
||||
}
|
||||
if sendTelegram {
|
||||
a.queueBackupTransfer("telegram", path)
|
||||
}
|
||||
if uploadGoogleDrive {
|
||||
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
||||
message := googleDriveUploadMessage(driveErr)
|
||||
a.finishBackupTransfer("googleDrive", path, message)
|
||||
deliveryMessages = append(deliveryMessages, message)
|
||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
||||
} else {
|
||||
a.finishBackupTransfer("googleDrive", path, "")
|
||||
}
|
||||
}
|
||||
if sendTelegram {
|
||||
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
||||
a.finishBackupTransfer("telegram", path, telegramErr.Error())
|
||||
deliveryMessages = append(deliveryMessages, "Telegram 发送失败:"+telegramErr.Error())
|
||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
||||
} else {
|
||||
a.finishBackupTransfer("telegram", path, "")
|
||||
}
|
||||
}
|
||||
err = errors.Join(deliveryErrors...)
|
||||
@@ -267,7 +301,11 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
a.backupMu.Lock()
|
||||
if err != nil {
|
||||
a.backupJob.Status = "failed"
|
||||
a.backupJob.Error = "本地备份或所选推送未全部完成,请查看服务日志"
|
||||
if len(deliveryMessages) > 0 {
|
||||
a.backupJob.Error = strings.Join(deliveryMessages, ";")
|
||||
} else {
|
||||
a.backupJob.Error = "本地备份创建失败,请检查服务器存储空间"
|
||||
}
|
||||
a.log.Error("create disaster backup", "error", err)
|
||||
} else {
|
||||
a.backupJob.Status = "success"
|
||||
@@ -810,6 +848,10 @@ func (a *App) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(path + ".sha256")
|
||||
a.backupMu.Lock()
|
||||
delete(a.backupTransfers, backupTransferKey("telegram", path))
|
||||
delete(a.backupTransfers, backupTransferKey("googleDrive", path))
|
||||
a.backupMu.Unlock()
|
||||
respondJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -822,12 +864,21 @@ func (a *App) handleSendBackupTelegram(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, 404, "备份不存在")
|
||||
return
|
||||
}
|
||||
if err := a.sendBackupToTelegram(r.Context(), path); err != nil {
|
||||
a.log.Error("send backup telegram", "error", err)
|
||||
respondError(w, 502, err.Error())
|
||||
if !a.startBackupTransfer("telegram", path) {
|
||||
respondError(w, http.StatusConflict, "该备份正在发送到 Telegram")
|
||||
return
|
||||
}
|
||||
respondJSON(w, 200, map[string]any{"ok": true})
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||
defer cancel()
|
||||
if err := a.sendBackupToTelegram(ctx, path); err != nil {
|
||||
a.finishBackupTransfer("telegram", path, err.Error())
|
||||
a.log.Error("send backup telegram", "error", err)
|
||||
return
|
||||
}
|
||||
a.finishBackupTransfer("telegram", path, "")
|
||||
}()
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -839,12 +890,89 @@ func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request
|
||||
respondError(w, 404, "备份不存在")
|
||||
return
|
||||
}
|
||||
if err := a.uploadBackupToGoogleDrive(r.Context(), path); err != nil {
|
||||
a.log.Error("upload backup to google drive", "error", err)
|
||||
respondError(w, 502, "上传 Google 云端硬盘失败")
|
||||
if !a.startBackupTransfer("googleDrive", path) {
|
||||
respondError(w, http.StatusConflict, "该备份正在上传到 Google 云端硬盘")
|
||||
return
|
||||
}
|
||||
respondJSON(w, 200, map[string]bool{"ok": true})
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||
defer cancel()
|
||||
if err := a.uploadBackupToGoogleDrive(ctx, path); err != nil {
|
||||
message := googleDriveUploadMessage(err)
|
||||
a.finishBackupTransfer("googleDrive", path, message)
|
||||
a.log.Error("upload backup to google drive", "error", err)
|
||||
return
|
||||
}
|
||||
a.finishBackupTransfer("googleDrive", path, "")
|
||||
}()
|
||||
respondJSON(w, http.StatusAccepted, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func backupTransferKey(provider, path string) string {
|
||||
return provider + ":" + filepath.Base(path)
|
||||
}
|
||||
|
||||
func (a *App) startBackupTransfer(provider, path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
key := backupTransferKey(provider, path)
|
||||
a.backupMu.Lock()
|
||||
defer a.backupMu.Unlock()
|
||||
if transfer := a.backupTransfers[key]; transfer != nil && transfer.Status == "running" {
|
||||
return false
|
||||
}
|
||||
a.backupTransfers[key] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "running", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) ensureBackupTransfer(provider, path string) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
key := backupTransferKey(provider, path)
|
||||
a.backupMu.Lock()
|
||||
defer a.backupMu.Unlock()
|
||||
if transfer := a.backupTransfers[key]; transfer == nil {
|
||||
a.backupTransfers[key] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "running", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||
} else if transfer.Status == "queued" {
|
||||
transfer.Status = "running"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) queueBackupTransfer(provider, path string) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.backupMu.Lock()
|
||||
defer a.backupMu.Unlock()
|
||||
a.backupTransfers[backupTransferKey(provider, path)] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "queued", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||
}
|
||||
|
||||
func (a *App) updateBackupTransfer(provider, path string, uploaded int64) {
|
||||
a.backupMu.Lock()
|
||||
defer a.backupMu.Unlock()
|
||||
if transfer := a.backupTransfers[backupTransferKey(provider, path)]; transfer != nil {
|
||||
transfer.Uploaded = uploaded
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) finishBackupTransfer(provider, path, message string) {
|
||||
a.backupMu.Lock()
|
||||
defer a.backupMu.Unlock()
|
||||
if transfer := a.backupTransfers[backupTransferKey(provider, path)]; transfer != nil {
|
||||
transfer.FinishedAt = a.now().UTC()
|
||||
if message == "" {
|
||||
transfer.Status = "success"
|
||||
transfer.Uploaded = transfer.Total
|
||||
} else {
|
||||
transfer.Status = "failed"
|
||||
transfer.Error = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) googleDriveClient(ctx context.Context) (*http.Client, error) {
|
||||
@@ -898,7 +1026,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return "", fmt.Errorf("drive folder lookup %s: %s", resp.Status, raw)
|
||||
return "", &googleDriveAPIError{Operation: "folder lookup", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||
}
|
||||
var list struct {
|
||||
Files []struct {
|
||||
@@ -921,7 +1049,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return "", fmt.Errorf("drive folder create %s: %s", resp.Status, raw)
|
||||
return "", &googleDriveAPIError{Operation: "folder create", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
@@ -936,6 +1064,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
||||
}
|
||||
|
||||
func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error {
|
||||
a.ensureBackupTransfer("googleDrive", path)
|
||||
client, err := a.googleDriveClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -945,7 +1074,7 @@ func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := newGoogleDriveUploadRequest(ctx, path, folderID)
|
||||
req, size, err := newGoogleDriveResumableRequest(ctx, path, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -956,53 +1085,107 @@ func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("drive upload %s: %s", resp.Status, raw)
|
||||
return &googleDriveAPIError{Operation: "start upload", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||
}
|
||||
return nil
|
||||
location := strings.TrimSpace(resp.Header.Get("Location"))
|
||||
if location == "" {
|
||||
return errors.New("Google 云端硬盘未返回可恢复上传地址")
|
||||
}
|
||||
return a.uploadGoogleDriveChunks(ctx, client, location, path, size)
|
||||
}
|
||||
|
||||
func newGoogleDriveUploadRequest(ctx context.Context, path, folderID string) (*http.Request, error) {
|
||||
type googleDriveAPIError struct {
|
||||
Operation string
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *googleDriveAPIError) Error() string {
|
||||
return fmt.Sprintf("google drive %s returned %d: %s", e.Operation, e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func newGoogleDriveResumableRequest(ctx context.Context, path, folderID string) (*http.Request, int64, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{"name": filepath.Base(path), "parents": []string{folderID}})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name", strings.NewReader(string(metadata)))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||||
req.Header.Set("X-Upload-Content-Type", "application/octet-stream")
|
||||
req.Header.Set("X-Upload-Content-Length", fmt.Sprint(info.Size()))
|
||||
return req, info.Size(), nil
|
||||
}
|
||||
|
||||
func (a *App) uploadGoogleDriveChunks(ctx context.Context, client *http.Client, location, path string, size int64) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
reader, writerSide := io.Pipe()
|
||||
mw := multipart.NewWriter(writerSide)
|
||||
go func() {
|
||||
var writeErr error
|
||||
defer func() { _ = file.Close(); _ = mw.Close(); _ = writerSide.CloseWithError(writeErr) }()
|
||||
head := textproto.MIMEHeader{}
|
||||
head.Set("Content-Type", "application/json; charset=UTF-8")
|
||||
part, err := mw.CreatePart(head)
|
||||
defer file.Close()
|
||||
for offset := int64(0); offset < size; {
|
||||
length := int64(googleDriveUploadChunkSize)
|
||||
if remaining := size - offset; remaining < length {
|
||||
length = remaining
|
||||
}
|
||||
end := offset + length - 1
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, location, io.NewSectionReader(file, offset, length))
|
||||
if err != nil {
|
||||
writeErr = err
|
||||
return
|
||||
return err
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{"name": filepath.Base(path), "parents": []string{folderID}})
|
||||
if _, err = part.Write(metadata); err != nil {
|
||||
writeErr = err
|
||||
return
|
||||
}
|
||||
head = textproto.MIMEHeader{}
|
||||
head.Set("Content-Type", "application/octet-stream")
|
||||
part, err = mw.CreatePart(head)
|
||||
req.ContentLength = length
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, end, size))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
writeErr = err
|
||||
return
|
||||
return err
|
||||
}
|
||||
_, writeErr = io.Copy(part, file)
|
||||
}()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name", reader)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
_ = writerSide.CloseWithError(err)
|
||||
return nil, err
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusPermanentRedirect {
|
||||
offset += length
|
||||
a.updateBackupTransfer("googleDrive", path, offset)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 && end+1 == size {
|
||||
a.updateBackupTransfer("googleDrive", path, size)
|
||||
return nil
|
||||
}
|
||||
return &googleDriveAPIError{Operation: "upload chunk", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||
}
|
||||
req.Header.Set("Content-Type", "multipart/related; boundary="+mw.Boundary())
|
||||
return req, nil
|
||||
return errors.New("Google 云端硬盘不能上传空备份文件")
|
||||
}
|
||||
|
||||
func googleDriveUploadMessage(err error) string {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return "Google 云端硬盘上传超时,请检查服务器网络后重试"
|
||||
}
|
||||
var apiErr *googleDriveAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
body := strings.ToLower(apiErr.Body)
|
||||
switch {
|
||||
case apiErr.StatusCode == http.StatusUnauthorized || strings.Contains(body, "invalid_grant"):
|
||||
return "Google 授权已失效,请打开配置,断开后重新连接"
|
||||
case strings.Contains(body, "storagequota") || strings.Contains(body, "storage quota"):
|
||||
return "Google 云端硬盘空间不足,请清理空间后重试"
|
||||
case apiErr.StatusCode == http.StatusTooManyRequests || strings.Contains(body, "ratelimit"):
|
||||
return "Google 云端硬盘请求过于频繁,请稍后重试"
|
||||
case apiErr.StatusCode == http.StatusForbidden:
|
||||
return "Google 云端硬盘无上传权限,请确认 Drive API 已启用并重新连接"
|
||||
}
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
if strings.Contains(message, "oauth2") || strings.Contains(message, "token") {
|
||||
return "Google 授权已失效,请打开配置,断开后重新连接"
|
||||
}
|
||||
return "上传 Google 云端硬盘失败,请检查服务器网络或重新连接 Google 账号"
|
||||
}
|
||||
|
||||
func (a *App) sendBackupToTelegram(ctx context.Context, path string) error {
|
||||
a.ensureBackupTransfer("telegram", path)
|
||||
schedule, _ := a.loadBackupSchedule(ctx)
|
||||
token, chatID, err := a.backupTelegramCredentials(ctx, schedule)
|
||||
if err != nil {
|
||||
@@ -1137,7 +1320,7 @@ func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path stri
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
_, writeErr = io.Copy(part, file)
|
||||
_, writeErr = io.Copy(part, &backupProgressReader{reader: file, onProgress: func(uploaded int64) { a.updateBackupTransfer("telegram", path, uploaded) }})
|
||||
}()
|
||||
endpoint := strings.TrimRight(a.telegramURL, "/") + "/bot" + token + "/sendDocument"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reader)
|
||||
@@ -1157,6 +1340,21 @@ func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path stri
|
||||
return nil
|
||||
}
|
||||
|
||||
type backupProgressReader struct {
|
||||
reader io.Reader
|
||||
uploaded int64
|
||||
onProgress func(int64)
|
||||
}
|
||||
|
||||
func (r *backupProgressReader) Read(p []byte) (int, error) {
|
||||
n, err := r.reader.Read(p)
|
||||
if n > 0 {
|
||||
r.uploaded += int64(n)
|
||||
r.onProgress(r.uploaded)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (a *App) listBackups() ([]backupItem, error) {
|
||||
dir := a.config().BackupDir
|
||||
if dir == "" {
|
||||
@@ -1302,18 +1500,34 @@ func (a *App) runScheduledBackup(ctx context.Context) {
|
||||
path, runErr := a.createDisasterBackup(ctx, password)
|
||||
password = ""
|
||||
localBackupSucceeded := runErr == nil
|
||||
var deliveryMessages []string
|
||||
if localBackupSucceeded {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, _ = a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupScheduleLastRun',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, now, now)
|
||||
var deliveryErrors []error
|
||||
if schedule.GoogleDriveEnabled {
|
||||
a.queueBackupTransfer("googleDrive", path)
|
||||
}
|
||||
if schedule.TelegramEnabled {
|
||||
a.queueBackupTransfer("telegram", path)
|
||||
}
|
||||
if schedule.GoogleDriveEnabled {
|
||||
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
||||
message := googleDriveUploadMessage(driveErr)
|
||||
a.finishBackupTransfer("googleDrive", path, message)
|
||||
deliveryMessages = append(deliveryMessages, message)
|
||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
||||
} else {
|
||||
a.finishBackupTransfer("googleDrive", path, "")
|
||||
}
|
||||
}
|
||||
if schedule.TelegramEnabled {
|
||||
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
||||
a.finishBackupTransfer("telegram", path, telegramErr.Error())
|
||||
deliveryMessages = append(deliveryMessages, "Telegram 发送失败:"+telegramErr.Error())
|
||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
||||
} else {
|
||||
a.finishBackupTransfer("telegram", path, "")
|
||||
}
|
||||
}
|
||||
runErr = errors.Join(deliveryErrors...)
|
||||
@@ -1322,7 +1536,11 @@ func (a *App) runScheduledBackup(ctx context.Context) {
|
||||
publicError := ""
|
||||
if runErr != nil {
|
||||
status = "failed"
|
||||
publicError = "定时备份或云端推送失败,请查看服务日志"
|
||||
if len(deliveryMessages) > 0 {
|
||||
publicError = strings.Join(deliveryMessages, ";")
|
||||
} else {
|
||||
publicError = "定时备份创建失败,请检查服务器存储空间"
|
||||
}
|
||||
a.log.Error("scheduled backup", "error", runErr)
|
||||
}
|
||||
a.backupMu.Lock()
|
||||
|
||||
@@ -3,9 +3,8 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -58,42 +57,109 @@ func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveUploadRequestUsesMultipartRelated(t *testing.T) {
|
||||
func TestGoogleDriveResumableRequest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
|
||||
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err := newGoogleDriveUploadRequest(context.Background(), path, "folder-123")
|
||||
req, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "multipart/related" || params["boundary"] == "" {
|
||||
t.Fatalf("content type = %q, %v", req.Header.Get("Content-Type"), err)
|
||||
if size != int64(len("encrypted backup")) {
|
||||
t.Fatalf("upload size = %d", size)
|
||||
}
|
||||
reader := multipart.NewReader(req.Body, params["boundary"])
|
||||
metadataPart, err := reader.NextPart()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if req.URL.Query().Get("uploadType") != "resumable" || req.Header.Get("X-Upload-Content-Length") != fmt.Sprint(size) {
|
||||
t.Fatalf("resumable request = %s headers=%v", req.URL, req.Header)
|
||||
}
|
||||
var metadata struct {
|
||||
Name string `json:"name"`
|
||||
Parents []string `json:"parents"`
|
||||
}
|
||||
if err := json.NewDecoder(metadataPart).Decode(&metadata); err != nil {
|
||||
if err := json.NewDecoder(req.Body).Decode(&metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
|
||||
t.Fatalf("metadata = %+v", metadata)
|
||||
}
|
||||
filePart, err := reader.NextPart()
|
||||
}
|
||||
|
||||
func TestBackupProgressReaderReportsBytes(t *testing.T) {
|
||||
var updates []int64
|
||||
reader := &backupProgressReader{reader: strings.NewReader("encrypted backup"), onProgress: func(uploaded int64) {
|
||||
updates = append(updates, uploaded)
|
||||
}}
|
||||
raw, err := io.ReadAll(reader)
|
||||
if err != nil || string(raw) != "encrypted backup" {
|
||||
t.Fatalf("read = %q, %v", raw, err)
|
||||
}
|
||||
if len(updates) == 0 || updates[len(updates)-1] != int64(len(raw)) {
|
||||
t.Fatalf("progress updates = %v", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveUploadMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
status int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{http.StatusUnauthorized, `{}`, "授权已失效"},
|
||||
{http.StatusForbidden, `{"reason":"storageQuotaExceeded"}`, "空间不足"},
|
||||
{http.StatusForbidden, `{}`, "无上传权限"},
|
||||
{http.StatusTooManyRequests, `{}`, "请求过于频繁"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
message := googleDriveUploadMessage(&googleDriveAPIError{Operation: "upload", StatusCode: test.status, Body: test.body})
|
||||
if !strings.Contains(message, test.want) {
|
||||
t.Fatalf("message %q does not contain %q", message, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveChunkUploadAndProgress(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "large-backup.tar.zst.enc")
|
||||
size := int64(googleDriveUploadChunkSize + 3)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := io.ReadAll(filePart)
|
||||
if err != nil || string(raw) != "encrypted backup" {
|
||||
t.Fatalf("uploaded bytes = %q, %v", raw, err)
|
||||
if err := file.Truncate(size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = file.Close()
|
||||
var ranges []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ranges = append(ranges, r.Header.Get("Content-Range"))
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
if len(ranges) == 1 {
|
||||
w.WriteHeader(http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"id":"uploaded"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
if !a.startBackupTransfer("googleDrive", path) {
|
||||
t.Fatal("failed to start transfer")
|
||||
}
|
||||
if err := a.uploadGoogleDriveChunks(context.Background(), server.Client(), server.URL, path, size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantRanges := []string{
|
||||
fmt.Sprintf("bytes 0-%d/%d", googleDriveUploadChunkSize-1, size),
|
||||
fmt.Sprintf("bytes %d-%d/%d", googleDriveUploadChunkSize, size-1, size),
|
||||
}
|
||||
if len(ranges) != len(wantRanges) || ranges[0] != wantRanges[0] || ranges[1] != wantRanges[1] {
|
||||
t.Fatalf("content ranges = %v, want %v", ranges, wantRanges)
|
||||
}
|
||||
transfer := a.backupTransfers[backupTransferKey("googleDrive", path)]
|
||||
if transfer == nil || transfer.Uploaded != size {
|
||||
t.Fatalf("transfer = %+v", transfer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +316,19 @@ func TestManualBackupReusesSavedPassword(t *testing.T) {
|
||||
if err != nil || password != "SharedBackupPassword9" {
|
||||
t.Fatalf("saved password changed: %q, %v", password, err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
a.backupMu.Lock()
|
||||
status := a.backupJob.Status
|
||||
a.backupMu.Unlock()
|
||||
if status != "running" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("manual backup did not finish before timeout")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicServerIPValidation(t *testing.T) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/ianaindex"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
type maildirMailbox struct {
|
||||
@@ -822,6 +823,14 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) {
|
||||
if charset == "utf-8" || charset == "us-ascii" {
|
||||
return input, nil
|
||||
}
|
||||
// GB2312 is commonly used as a label for GBK-compatible mail content.
|
||||
// ianaindex does not consistently resolve these real-world aliases.
|
||||
switch charset {
|
||||
case "gb2312", "gb_2312-80", "x-gbk", "euc-cn", "cp936", "ms936", "windows-936":
|
||||
return simplifiedchinese.GBK.NewDecoder().Reader(input), nil
|
||||
case "gb18030":
|
||||
return simplifiedchinese.GB18030.NewDecoder().Reader(input), nil
|
||||
}
|
||||
enc, err := ianaindex.IANA.Encoding(charset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -355,6 +356,37 @@ func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaildirMessageDecodesAppleGB2312(t *testing.T) {
|
||||
subject := "验证 Apple 账户电子邮件地址"
|
||||
body := "你的 Apple 验证码是 978534"
|
||||
encodedSubject, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(subject))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := []byte("From: Apple <appleid@id.apple.com>\r\n" +
|
||||
"To: admin@example.com\r\n" +
|
||||
"Subject: =?gb2312?B?" + base64.StdEncoding.EncodeToString(encodedSubject) + "?=\r\n" +
|
||||
"Content-Type: text/plain; charset=gb2312\r\n" +
|
||||
"Content-Transfer-Encoding: base64\r\n\r\n" +
|
||||
base64.StdEncoding.EncodeToString(encodedBody))
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.Subject != subject {
|
||||
t.Fatalf("GB2312 subject was not decoded: %q", msg.Subject)
|
||||
}
|
||||
if msg.BodyText != body {
|
||||
t.Fatalf("GB2312 body was not decoded: %q", msg.BodyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Mail } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("grid size-9 shrink-0 place-items-center rounded-md border border-primary/20 bg-primary/[0.03] text-primary", className)} aria-hidden="true">
|
||||
<Mail className="size-6 stroke-[1.8]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as React from "react"
|
||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||
import { ArchiveRestore, ClipboardList, Forward, Globe2, Inbox, LayoutDashboard, LogOut, 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"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { BrandMark } from "@/components/brand-mark"
|
||||
import { hasAnyPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import {
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "overview", label: "仪表盘", icon: <LayoutDashboard />, permissions: ["admin.overview.view"] },
|
||||
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
|
||||
{ key: "permissionGroups", label: "权限配置", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||
@@ -72,9 +73,7 @@ function ProtectedContent() {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<BrandMark className="size-8 rounded-md [&>svg]:size-5" />
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
||||
</div>
|
||||
@@ -115,7 +114,7 @@ function ProtectedContent() {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div className="p-2">
|
||||
<Button variant="outline" size="sm" className="w-full gap-2 text-xs" onClick={logout}>
|
||||
<Button variant="outline" size="sm" className="w-full gap-2 border-destructive/35 text-xs text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
|
||||
<LogOut className="h-3.5 w-3.5" />退出登录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"bg-[hsl(var(--action-primary))] text-[hsl(var(--action-primary-foreground))] hover:bg-[hsl(var(--action-primary)/0.9)]",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
|
||||
@@ -519,7 +519,7 @@ const SidebarMenuItem = React.forwardRef<
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:font-medium data-[active=true]:text-[hsl(var(--sidebar-active-foreground))] data-[active=true]:hover:bg-[hsl(var(--sidebar-active))] data-[active=true]:hover:text-[hsl(var(--sidebar-active-foreground))] data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -621,7 +621,7 @@ const SidebarMenuAction = React.forwardRef<
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))] md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -639,7 +639,7 @@ const SidebarMenuBadge = React.forwardRef<
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-[hsl(var(--sidebar-active-foreground))]",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
@@ -730,7 +730,7 @@ const SidebarMenuSubButton = React.forwardRef<
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-[hsl(var(--sidebar-active))] data-[active=true]:text-[hsl(var(--sidebar-active-foreground))]",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
|
||||
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
"fixed inset-x-0 top-0 z-[100] flex max-h-screen w-full flex-col items-end gap-2 p-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:left-auto sm:right-0 sm:max-w-[420px] sm:p-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,11 +25,11 @@ const ToastViewport = React.forwardRef<
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
"group pointer-events-auto relative flex w-full items-start justify-between gap-3 overflow-hidden rounded-md border bg-popover p-4 pr-9 text-popover-foreground shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-3 sm:data-[state=open]:slide-in-from-right-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
default: "border-border/80 bg-popover text-popover-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
@@ -81,7 +81,7 @@ const ToastClose = React.forwardRef<
|
||||
props.onClick?.(event)
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring group-[.destructive]:text-red-200 group-[.destructive]:hover:bg-red-950/20 group-[.destructive]:hover:text-white group-[.destructive]:focus:ring-red-300",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
@@ -98,7 +98,7 @@ const ToastTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
className={cn("break-words text-sm font-semibold leading-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -110,7 +110,7 @@ const ToastDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
className={cn("line-clamp-3 break-all text-sm leading-5 text-muted-foreground group-[.destructive]:text-destructive-foreground/90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -14,11 +14,11 @@ export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ToastProvider duration={5000} swipeDirection="right">
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
const TOAST_LIMIT = 3
|
||||
const TOAST_REMOVE_DELAY = 1000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
|
||||
+31
-2
@@ -12,10 +12,12 @@
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 0 0% 12%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--action-primary: 0 0% 12%;
|
||||
--action-primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 12%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 42%;
|
||||
--muted-foreground: 0 0% 36%;
|
||||
--accent: 0 0% 95%;
|
||||
--accent-foreground: 0 0% 12%;
|
||||
--destructive: 358 88% 61%;
|
||||
@@ -33,6 +35,11 @@
|
||||
--sidebar-accent-foreground: 0 0% 12%;
|
||||
--sidebar-border: 0 0% 89.8%;
|
||||
--sidebar-ring: 0 0% 42%;
|
||||
--sidebar-active: 207 100% 92%;
|
||||
--sidebar-active-foreground: 202 100% 30%;
|
||||
--mail-selected: 210 100% 96%;
|
||||
--compose-send: 217 89% 43%;
|
||||
--compose-send-hover: 216 94% 32%;
|
||||
}
|
||||
|
||||
* { @apply border-border; }
|
||||
@@ -61,10 +68,12 @@
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--action-primary: 240 4% 24%;
|
||||
--action-primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--muted-foreground: 240 5% 72%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
@@ -80,10 +89,25 @@
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-active: 210 48% 24%;
|
||||
--sidebar-active-foreground: 210 100% 88%;
|
||||
--mail-selected: 210 38% 20%;
|
||||
--compose-send: 217 89% 52%;
|
||||
--compose-send-hover: 214 94% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.compose-send-button.compose-send-button {
|
||||
background: hsl(var(--compose-send));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.compose-send-button.compose-send-button:hover {
|
||||
background: hsl(var(--compose-send-hover));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mail-shell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
|
||||
@@ -106,6 +130,11 @@
|
||||
max-width: 384px;
|
||||
}
|
||||
|
||||
.mail-selected-row {
|
||||
background-color: hsl(var(--mail-selected));
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mail-content-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -53,7 +53,11 @@ export type PermissionGroup = { id: string; name: string; description: string; p
|
||||
export type User = { id: string; loginName?: 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[]; storageQuotaMb: number }
|
||||
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
||||
export type AdminOverview = {
|
||||
users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number
|
||||
aliases: number; messages: number; unreadMessages: number; storageBytes: number
|
||||
todaySent: number; todayReceived: number; sendDelivered: number; sendFailed: number; queueMessages: number
|
||||
}
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
@@ -204,9 +208,10 @@ export type SystemUpdateResult = {
|
||||
}
|
||||
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
|
||||
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
|
||||
export type BackupTransfer = { provider: "telegram" | "googleDrive"; name: string; status: "queued" | "running" | "success" | "failed"; uploaded: number; total: number; startedAt: string; finishedAt?: string; error?: string }
|
||||
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; passwordHint?: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
|
||||
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
|
||||
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus }
|
||||
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus; transfers: BackupTransfer[] }
|
||||
export type SystemSettings = {
|
||||
publicHostname: string
|
||||
publicBaseUrl: string
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { LanguageDomSync } from "@/lib/language"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { ProtectedLayout } from "@/components/protected-layout"
|
||||
import { AdminOnly } from "@/components/admin-only"
|
||||
import "./index.css"
|
||||
@@ -15,6 +16,8 @@ const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ d
|
||||
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
|
||||
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
|
||||
|
||||
applyTheme(getInitialTheme())
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
|
||||
+207
-185
@@ -2,8 +2,8 @@ import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Cloud, Copy, Download, ExternalLink, Eye, EyeOff, Github, Globe2, HardDrive, KeyRound, Loader2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, Send, 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 { AlertCircle, CheckCircle2, ChevronDown, ChevronRight, Circle, ClipboardList, Clock3, Cloud, Copy, Database, Download, ExternalLink, Eye, EyeOff, Globe2, HardDrive, KeyRound, Loader2, Mail, MoreHorizontal, RefreshCcw, Search, Send, ShieldCheck, Trash2, UserRound } from "lucide-react"
|
||||
import { api, AdminOverview, 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"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -20,27 +20,26 @@ import { Switch } from "@/components/ui/switch"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey, TelegramPairing } from "@/lib/api-types"
|
||||
import type { BackupTransfer, PermissionKey, TelegramPairing } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||
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: "查看发信投递、重试和失败记录。" },
|
||||
backups: { label: "备份与恢复", frontLabel: "数据保护", description: "创建、校验和下载可迁移的加密完整备份。" },
|
||||
settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
|
||||
const sectionMeta: Record<Section, { label: string; description: string }> = {
|
||||
overview: { label: "仪表盘", description: "邮件运行、域名与系统状态集中查看。" },
|
||||
users: { label: "账号管理", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" },
|
||||
permissionGroups: { label: "权限配置", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
|
||||
domains: { label: "域名管理", description: "维护邮件域名、DKIM 和 DNS 检测。" },
|
||||
mailboxes: { label: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" },
|
||||
aliases: { label: "邮件转发", description: "管理域名转发规则。" },
|
||||
messages: { label: "全部邮件", description: "按邮箱、文件夹和关键词查看全站邮件。" },
|
||||
sendAudit: { label: "发送队列", description: "查看发信投递、重试和失败记录。" },
|
||||
backups: { label: "备份与恢复", description: "创建、校验和下载可迁移的加密完整备份。" },
|
||||
settings: { label: "系统设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
|
||||
}
|
||||
const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string>
|
||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||
@@ -56,8 +55,6 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
backups: ["admin.settings.view"],
|
||||
settings: ["admin.settings.view", "admin.templates.view"],
|
||||
}
|
||||
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
|
||||
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
|
||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
const defaultMailboxLimitOverride = 9
|
||||
const defaultUserStorageQuotaMb = 100
|
||||
@@ -133,23 +130,26 @@ export function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const overviewChecklist = setupChecklist(overview.data, domainItems, settings.data).filter((item) => visibleSections.includes(item.section))
|
||||
const changeSection = (next: Section) => setParams(next === "overview" ? {} : { section: next })
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||||
<main className="admin-page mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||
<main className="admin-page mx-auto w-full max-w-[1320px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4">
|
||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} checklist={section === "overview" ? overviewChecklist : undefined} onSectionChange={changeSection} />
|
||||
|
||||
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||
|
||||
{section === "overview" && canOverview && (
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
||||
<Stat icon={<Globe2 />} label="邮件域名" value={overview.data?.domains || 0} />
|
||||
<Stat icon={<Mailbox />} label="邮箱" value={overview.data?.mailboxes || 0} />
|
||||
<Stat icon={<ShieldCheck />} label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} />
|
||||
</div>
|
||||
<section className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat icon={<UserRound />} tone="primary" label="账号" value={overview.data?.users || 0} detail={`${overview.data?.activeUsers || 0} 个活跃`} />
|
||||
<Stat icon={<Globe2 />} tone="cyan" label="邮件域名" value={overview.data?.domains || 0} detail={domainItems.some((domain) => domain.dnsStatus === "ok") ? `${domainItems.filter((domain) => domain.dnsStatus === "ok").length} 个 DNS 正常` : "待检测"} />
|
||||
<Stat icon={<Mail />} tone="sky" label="邮箱" value={overview.data?.mailboxes || 0} detail={`${overview.data?.activeMailboxes || 0} 个活跃`} />
|
||||
<Stat icon={<Database />} tone="violet" label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} detail={`${overview.data?.unreadMessages || 0} 封未读 · ${overview.data?.aliases || 0} 个转发`} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />}
|
||||
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={changeSection} />}
|
||||
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} domains={domainItems} />}
|
||||
{section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
|
||||
{section === "domains" && <DomainsSection domains={domainItems} />}
|
||||
@@ -164,23 +164,22 @@ export function AdminPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section; refreshing: boolean; onRefresh: () => void }) {
|
||||
type SetupChecklistItem = ReturnType<typeof setupChecklist>[number]
|
||||
|
||||
function AdminPageHeader({ section, refreshing, onRefresh, checklist, onSectionChange }: { section: Section; refreshing: boolean; onRefresh: () => void; checklist?: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) {
|
||||
const meta = sectionMeta[section]
|
||||
return (
|
||||
<div className="mb-4 border-b pb-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="mb-4 border-b border-border/80 pb-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>后台管理</span>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/50" />
|
||||
<span>前台:{meta.frontLabel}</span>
|
||||
</div>
|
||||
<h1 className="text-[20px] font-semibold leading-7 tracking-tight">{meta.label}</h1>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">{meta.description}</p>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground/80">{meta.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
|
||||
{checklist && <SetupChecklistDialog checklist={checklist} onSectionChange={onSectionChange} />}
|
||||
<Button type="button" variant="outline" size="sm" className="h-9 gap-2 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
|
||||
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
||||
<span className="hidden sm:inline">刷新</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,59 +187,107 @@ function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section;
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
function SetupChecklistDialog({ checklist, onSectionChange }: { checklist: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const completed = checklist.filter((item) => item.done).length
|
||||
const complete = checklist.length > 0 && completed === checklist.length
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>系统状态</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InfoBox label="活跃账号" value={overview?.activeUsers || 0} />
|
||||
<InfoBox label="活跃邮箱" value={overview?.activeMailboxes || 0} />
|
||||
<InfoBox label="邮件转发" value={overview?.aliases || 0} />
|
||||
<InfoBox label="未读邮件" value={overview?.unreadMessages || 0} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>首次配置</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{checklist.map((item) => (
|
||||
<Button key={item.key} type="button" variant="outline" className="h-auto w-full justify-start gap-3 px-3 py-2 text-left font-normal" onClick={() => onSectionChange(item.section)}>
|
||||
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{item.title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{item.detail}</span>
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm" className="h-9 gap-2 shadow-none">
|
||||
{complete ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <Circle className="h-4 w-4 text-amber-600" />}
|
||||
<span>{complete ? "初始化完成" : `初始化 ${completed}/${checklist.length}`}</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-xl gap-3 p-5">
|
||||
<DialogHeader><DialogTitle>首次配置</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{checklist.map((item) => (
|
||||
<Button key={item.key} type="button" variant="outline" className="h-auto min-h-[62px] justify-start gap-3 px-3 py-2 text-left font-normal last:sm:col-span-2" onClick={() => { setOpen(false); onSectionChange(item.section) }}>
|
||||
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0 flex-1"><span className="block font-medium">{item.title}</span><span className="block truncate text-xs text-muted-foreground">{item.detail}</span></span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: AdminOverview; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
|
||||
const { toast } = useToast()
|
||||
const dnsOK = domains.length > 0 && domains.every((domain) => domain.dnsStatus === "ok")
|
||||
const dnsWarning = domains.length > 0 && domains.some((domain) => domain.dnsStatus === "ok")
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card className="border-border/80">
|
||||
<CardHeader className="px-4 pb-2 pt-3 sm:px-4"><CardTitle className="text-base">邮件运行概览</CardTitle></CardHeader>
|
||||
<CardContent className="px-4 pb-3 sm:px-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<OverviewMetric icon={<Send />} label="今日发送" value={overview?.todaySent || 0} tone="primary" />
|
||||
<OverviewMetric icon={<Download />} label="今日接收" value={overview?.todayReceived || 0} tone="success" />
|
||||
<OverviewMetric icon={<CheckCircle2 />} label="发送成功" value={overview?.sendDelivered || 0} tone="success" />
|
||||
<OverviewMetric icon={<AlertCircle />} label="发送失败" value={overview?.sendFailed || 0} tone={(overview?.sendFailed || 0) > 0 ? "danger" : "muted"} />
|
||||
<OverviewMetric icon={<Clock3 />} label="队列邮件" value={overview?.queueMessages || 0} tone={(overview?.queueMessages || 0) > 0 ? "warning" : "muted"} />
|
||||
<OverviewMetric icon={<Mail />} label="未读邮件" value={overview?.unreadMessages || 0} tone={(overview?.unreadMessages || 0) > 0 ? "primary" : "muted"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80">
|
||||
<CardHeader className="px-4 pb-2 pt-3"><CardTitle className="text-base">系统状态</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-2.5 px-4 pb-3 md:grid-cols-3">
|
||||
<div className="rounded-md border border-border/80 px-3 py-2">
|
||||
<DashboardGroupTitle>系统健康</DashboardGroupTitle>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<DashboardStatusItem label="系统" status={<LightStatus state="success" label="运行中" />} />
|
||||
<DashboardStatusItem label="DNS" status={<LightStatus state={dnsOK ? "success" : dnsWarning ? "warning" : "muted"} label={dnsOK ? "正常" : dnsWarning ? "部分正常" : domains.length ? "未检测" : "未配置"} />} />
|
||||
<DashboardStatusItem label="SMTP" status={<LightStatus state={settings?.smtpHost ? "success" : "warning"} label={settings?.smtpHost ? "已配置" : "未配置"} />} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/80 px-3 py-2">
|
||||
<DashboardGroupTitle>服务信息</DashboardGroupTitle>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DashboardInfoItem label="公网地址" value={settings?.publicBaseUrl || "-"} onCopy={settings?.publicBaseUrl ? () => copyOverviewValue(settings.publicBaseUrl, "公网地址", toast) : undefined} />
|
||||
<DashboardInfoItem label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "未配置"} onCopy={settings?.smtpHost ? () => copyOverviewValue(`${settings.smtpHost}:${settings.smtpPort}`, "SMTP 地址", toast) : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/80 px-3 py-2">
|
||||
<DashboardGroupTitle>功能状态</DashboardGroupTitle>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DashboardStatusItem label="注册" status={<LightStatus state={settings?.openRegistration ? "success" : "muted"} label={settings?.openRegistration ? "已开放" : "关闭"} />} />
|
||||
<DashboardStatusItem label="自助申请邮箱" status={<LightStatus state={settings?.userMailboxApplyEnabled ? "success" : "muted"} label={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0 px-4 pb-2 pt-3"><div className="flex items-baseline gap-2"><CardTitle className="text-base">域名状态</CardTitle><span className="text-xs text-muted-foreground">{domains.length} 个域名</span></div>{visibleSections.includes("domains") && <Button type="button" variant="ghost" size="sm" className="h-7 gap-1 px-2 text-xs" onClick={() => onSectionChange("domains")}>管理域名<ChevronRight className="h-3.5 w-3.5" /></Button>}</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
{domains.length > 0 ? <div className="overflow-hidden rounded-md border border-border/80">
|
||||
<div className="hidden grid-cols-[minmax(0,1fr)_120px_140px_150px_24px] items-center gap-3 border-b bg-muted/20 px-3 py-1.5 text-[11px] font-medium text-muted-foreground md:grid"><span>邮件域名</span><span>使用状态</span><span>DNS 状态</span><span>最近检测</span><span /></div>
|
||||
<div className="divide-y">{domains.slice(0, 5).map((domain) => {
|
||||
const dnsDisplay = dnsStatusDisplay(domain.dnsStatus)
|
||||
return <Button key={domain.id} type="button" variant="ghost" className="grid h-auto min-h-12 w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-none px-3 py-2 text-left font-normal transition-colors hover:bg-muted/35 md:grid-cols-[minmax(0,1fr)_120px_140px_150px_24px]" onClick={() => onSectionChange("domains")}>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{domain.name}</span>
|
||||
<span className="hidden md:block"><LightStatus state={domain.status === "active" ? "success" : "muted"} label={domain.status === "active" ? "已启用" : "已停用"} /></span>
|
||||
<span className="justify-self-end md:justify-self-start"><LightStatus state={dnsDisplay.state} label={dnsDisplay.label} /></span>
|
||||
<span className="hidden text-xs text-muted-foreground md:block">{domain.dnsCheckedAt ? formatDate(domain.dnsCheckedAt) : "尚未检测"}</span>
|
||||
<ChevronRight className="hidden h-4 w-4 text-muted-foreground md:block" />
|
||||
<span className="col-span-2 flex items-center gap-2 text-[11px] text-muted-foreground md:hidden"><span>{domain.status === "active" ? "已启用" : "已停用"}</span><span>·</span><span>{domain.dnsCheckedAt ? `检测于 ${formatDate(domain.dnsCheckedAt)}` : "尚未检测"}</span></span>
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DNS 状态</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{domains.map((domain) => <DomainBadgeRow key={domain.id} domain={domain} />)}
|
||||
{domains.length === 0 && <Empty text="暂无域名" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>运行提示</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} />
|
||||
<InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} />
|
||||
<InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} />
|
||||
<InfoLine label="自助申请邮箱" value={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
})}</div>
|
||||
{domains.length > 5 && <Button type="button" variant="ghost" className="h-auto w-full justify-start rounded-none border-t px-3 py-2 text-left text-xs font-normal text-muted-foreground transition-colors hover:bg-muted/35 hover:text-foreground" onClick={() => onSectionChange("domains")}>还有 {domains.length - 5} 个域名,查看全部</Button>}
|
||||
</div> : <Empty text="暂无邮件域名" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function setupChecklist(overview: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number } | undefined, domains: Domain[], settings?: SystemSettings) {
|
||||
function setupChecklist(overview: AdminOverview | undefined, domains: Domain[], settings?: SystemSettings) {
|
||||
const hasDomain = domains.length > 0
|
||||
const dnsReady = domains.some((domain) => domain.dnsStatus === "ok")
|
||||
const hasMailbox = (overview?.activeMailboxes || 0) > 0
|
||||
@@ -254,8 +301,40 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
|
||||
]
|
||||
}
|
||||
|
||||
function OverviewMetric({ icon, label, value, tone }: { icon: React.ReactNode; label: string; value: number; tone: "primary" | "success" | "warning" | "danger" | "muted" }) {
|
||||
return <div className="flex min-h-[50px] items-center gap-2 rounded-md border border-border/80 px-2 py-1"><div className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-full [&>svg]:h-3.5 [&>svg]:w-3.5", tone === "primary" && "bg-primary/5 text-primary", tone === "success" && "bg-emerald-500/10 text-emerald-600", tone === "warning" && "bg-amber-500/10 text-amber-600", tone === "danger" && "bg-destructive/10 text-destructive", tone === "muted" && "bg-muted text-muted-foreground")}>{icon}</div><div className="min-w-0"><div className="truncate text-[10px] leading-3 text-muted-foreground">{label}</div><div className={cn("text-base font-semibold leading-5 tabular-nums", tone === "success" && "text-emerald-700 dark:text-emerald-400", tone === "warning" && "text-amber-700 dark:text-amber-400", tone === "danger" && "text-destructive")}>{value}</div></div></div>
|
||||
}
|
||||
|
||||
function dnsStatusDisplay(status: string): { state: "success" | "warning" | "danger" | "muted"; label: string } {
|
||||
if (status === "ok") return { state: "success", label: "DNS 正常" }
|
||||
if (status === "error") return { state: "danger", label: "DNS 异常" }
|
||||
if (!status || status === "unchecked") return { state: "muted", label: "未检测" }
|
||||
return { state: "warning", label: "需检查" }
|
||||
}
|
||||
|
||||
function LightStatus({ state, label }: { state: "success" | "warning" | "danger" | "muted"; label: string }) {
|
||||
return <span className={cn("inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full px-1.5 text-xs font-medium", state === "success" && "bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-400", state === "warning" && "bg-amber-500/[0.07] text-amber-700 dark:text-amber-400", state === "danger" && "bg-destructive/[0.07] text-destructive", state === "muted" && "bg-muted/70 text-muted-foreground")}><span className={cn("h-1.5 w-1.5 rounded-full", state === "success" && "bg-emerald-600", state === "warning" && "bg-amber-500", state === "danger" && "bg-destructive", state === "muted" && "bg-muted-foreground/60")} />{label}</span>
|
||||
}
|
||||
|
||||
function DashboardGroupTitle({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mb-1.5 text-[11px] font-medium text-muted-foreground">{children}</div>
|
||||
}
|
||||
|
||||
function DashboardStatusItem({ label, status }: { label: string; status: React.ReactNode }) {
|
||||
return <div className="min-w-0"><div className="mb-0.5 truncate text-[10px] text-muted-foreground">{label}</div>{status}</div>
|
||||
}
|
||||
|
||||
function DashboardInfoItem({ label, value, onCopy }: { label: string; value: string; onCopy?: () => void }) {
|
||||
return <div className="flex min-w-0 items-end gap-1"><div className="min-w-0 flex-1"><div className="text-[10px] text-muted-foreground">{label}</div><div className="truncate text-xs font-medium" title={value}>{value}</div></div>{onCopy && <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={onCopy} title={`复制${label}`} aria-label={`复制${label}`}><Copy className="h-3 w-3" /></Button>}</div>
|
||||
}
|
||||
|
||||
async function copyOverviewValue(value: string, label: string, toast: ReturnType<typeof useToast>["toast"]) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast({ title: `${label}已复制` })
|
||||
}
|
||||
|
||||
function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
|
||||
return <div className="grid min-h-10 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-md border px-3 py-2"><span className="whitespace-nowrap">{label}</span><span className="min-w-0 break-all text-right font-medium text-foreground">{value}</span></div>
|
||||
}
|
||||
|
||||
function generateBackupPassword(length = 24) {
|
||||
@@ -271,7 +350,7 @@ function BackupsSection() {
|
||||
const backups = useQuery({
|
||||
queryKey: ["admin", "backups"],
|
||||
queryFn: api.backups,
|
||||
refetchInterval: (query) => query.state.data?.job?.status === "running" ? 2000 : false,
|
||||
refetchInterval: (query) => query.state.data?.job?.status === "running" || query.state.data?.transfers?.some((item) => item.status === "running" || item.status === "queued") ? 2000 : false,
|
||||
})
|
||||
const [createOpen, setCreateOpen] = React.useState(false)
|
||||
const [password, setPassword] = React.useState("")
|
||||
@@ -346,7 +425,7 @@ function BackupsSection() {
|
||||
})
|
||||
const sendTelegram = useMutation({
|
||||
mutationFn: api.sendBackupTelegram,
|
||||
onSuccess: () => toast({ title: "已发送到 Telegram" }),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已开始发送到 Telegram" }) },
|
||||
onError: (error) => toast({ title: "发送失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
})
|
||||
const testBackupTelegram = useMutation({
|
||||
@@ -370,7 +449,7 @@ function BackupsSection() {
|
||||
})
|
||||
const sendDrive = useMutation({
|
||||
mutationFn: api.sendBackupGoogleDrive,
|
||||
onSuccess: () => toast({ title: "已上传到 Google 云端硬盘" }),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已开始上传到 Google 云端硬盘" }) },
|
||||
onError: (error) => toast({ title: "上传失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
})
|
||||
const connectDrive = useMutation({
|
||||
@@ -445,6 +524,10 @@ function BackupsSection() {
|
||||
if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return }
|
||||
savePassword.mutate()
|
||||
}
|
||||
const transferGroups = Object.values((backups.data?.transfers || []).reduce<Record<string, BackupTransfer[]>>((groups, transfer) => {
|
||||
;(groups[transfer.name] ||= []).push(transfer)
|
||||
return groups
|
||||
}, {})).sort((left, right) => Date.parse(right[0]?.startedAt || "") - Date.parse(left[0]?.startedAt || ""))
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]">
|
||||
@@ -461,6 +544,18 @@ function BackupsSection() {
|
||||
{job?.status === "failed" && <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{job.error || "备份生成失败"}</div>}
|
||||
{job?.status === "success" && <div className="rounded-md border border-green-300 bg-green-50 px-3 py-2 text-sm text-green-800">最近一次备份已完成。</div>}
|
||||
{!job && <p className="text-sm text-muted-foreground">手动备份与定时备份共用同一个恢复密码,避免不同备份使用不同密码。</p>}
|
||||
{transferGroups.slice(0, 3).map((transfers) => <div key={transfers[0].name} className="space-y-3 rounded-md border px-3 py-3">
|
||||
<div className="truncate text-sm font-medium" title={transfers[0].name}>{transfers[0].name}</div>
|
||||
{transfers.sort((a, b) => (a.provider === "telegram" ? 0 : 1) - (b.provider === "telegram" ? 0 : 1)).map((transfer) => {
|
||||
const percent = transfer.total > 0 ? Math.min(100, Math.round(transfer.uploaded * 100 / transfer.total)) : 0
|
||||
const label = transfer.provider === "telegram" ? "Telegram" : "Google 云端硬盘"
|
||||
return <div key={transfer.provider} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-xs"><span>{label}</span><span className={cn("shrink-0", transfer.status === "failed" ? "text-destructive" : "text-muted-foreground")}>{transfer.status === "queued" ? "等待上传" : transfer.status === "running" ? `${percent}% · ${formatBytes(transfer.uploaded)} / ${formatBytes(transfer.total)}` : transfer.status === "success" ? "上传完成" : "上传失败"}</span></div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted"><div className={cn("h-full transition-[width]", transfer.status === "failed" ? "bg-destructive" : transfer.status === "success" ? "bg-green-600" : "bg-primary")} style={{ width: `${transfer.status === "success" ? 100 : percent}%` }} /></div>
|
||||
{transfer.error && <p className="text-xs text-destructive">{transfer.error}</p>}
|
||||
</div>
|
||||
})}
|
||||
</div>)}
|
||||
<div className="border-t pt-3">
|
||||
<div className="mb-2 flex items-center justify-between"><span className="text-sm font-medium">本地备份</span><span className="text-xs text-muted-foreground">保留最近 10 份</span></div>
|
||||
<div className="divide-y rounded-md border">
|
||||
@@ -550,6 +645,7 @@ function BackupsSection() {
|
||||
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
|
||||
<DialogHeader><DialogTitle>Google 云端硬盘</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"><div className="font-medium">连接前必须启用 Google Drive API</div><p className="mt-1 text-xs">请在 OAuth 客户端所属的同一个 Google Cloud 项目中启用 API。启用后若仍提示无权限,请先断开连接,再重新授权。</p><Button asChild type="button" variant="link" className="mt-1 h-auto p-0 text-amber-900"><a href="https://console.cloud.google.com/apis/library/drive.googleapis.com" target="_blank" rel="noreferrer">打开 Google Drive API <ExternalLink className="ml-1 h-3.5 w-3.5" /></a></Button></div>
|
||||
<div className="space-y-2"><Label htmlFor="google-client-id">OAuth 客户端 ID</Label><Input id="google-client-id" value={googleClientId} onChange={(e) => setGoogleClientId(e.target.value)} /></div>
|
||||
<div className="space-y-2"><Label htmlFor="google-client-secret">OAuth 客户端密钥</Label><Input id="google-client-secret" type="password" value={googleClientSecret} onChange={(e) => setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} /></div>
|
||||
<div className="space-y-2"><Label htmlFor="google-folder-name">备份文件夹</Label><Input id="google-folder-name" value={googleFolderName} onChange={(e) => setGoogleFolderName(e.target.value)} /></div>
|
||||
@@ -987,11 +1083,11 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||
<div key={domain.id} className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{domain.name}</div>
|
||||
<div className="text-xs text-muted-foreground">selector: {domain.dkimSelector}</div>
|
||||
<div className="text-xs text-muted-foreground">DKIM 选择器:{domain.dkimSelector}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusText active={domain.status === "active"} activeLabel="启用" inactiveLabel="停用" />
|
||||
<StatusText active={domain.dnsStatus === "ok"} activeLabel="DNS 正常" inactiveLabel={domain.dnsStatus || "未检测"} />
|
||||
<LightStatus state={dnsStatusDisplay(domain.dnsStatus).state} label={dnsStatusDisplay(domain.dnsStatus).label} />
|
||||
{canViewDNS && <DomainDNSDialog domain={domain} />}
|
||||
{canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>}
|
||||
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、转发和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" />删除</Button>}
|
||||
@@ -1054,7 +1150,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))
|
||||
const toggleOwner = (ownerID: string) => setExpandedOwners((current) => current.includes(ownerID) ? current.filter((id) => id !== ownerID) : [...current, ownerID])
|
||||
return (
|
||||
<Card>
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>邮箱管理</CardTitle>
|
||||
@@ -1064,7 +1160,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="min-w-0 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号或邮箱" className="pl-9" />
|
||||
@@ -1210,7 +1306,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId })
|
||||
const items = messages.data?.pages.flatMap((page) => page.items || []) || []
|
||||
return (
|
||||
<Card>
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>全部邮件</CardTitle>
|
||||
@@ -1219,7 +1315,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="min-w-0 space-y-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -1268,17 +1364,17 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<div className="min-w-0 overflow-hidden md:block max-md:hidden">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>邮件</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>发件人</TableHead>
|
||||
<TableHead>收件人</TableHead>
|
||||
<TableHead>文件夹</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
<TableHead className="w-[25%]">邮件</TableHead>
|
||||
<TableHead className="w-[17%]">邮箱</TableHead>
|
||||
<TableHead className="w-[15%]">发件人</TableHead>
|
||||
<TableHead className="w-[17%]">收件人</TableHead>
|
||||
<TableHead className="w-[9%]">文件夹</TableHead>
|
||||
<TableHead className="w-[10%]">时间</TableHead>
|
||||
<TableHead className="w-[7%]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -1288,9 +1384,9 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
<div className="truncate font-medium">{message.subject}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{message.snippet}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{message.mailboxAddress || message.recipientAddress || "-"}</div>
|
||||
{message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>}
|
||||
<TableCell className="min-w-0">
|
||||
<div className="truncate font-medium" title={message.mailboxAddress || message.recipientAddress || "-"}>{message.mailboxAddress || message.recipientAddress || "-"}</div>
|
||||
{message.ownerEmail && <div className="truncate text-xs text-muted-foreground" title={message.ownerEmail}>{message.ownerEmail}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px] truncate" title={adminSenderTitle(message)}>{adminSenderDisplayName(message)}</TableCell>
|
||||
<TableCell className="max-w-[220px] truncate">{message.recipientAddress || message.to?.join(", ") || ""}</TableCell>
|
||||
@@ -1444,7 +1540,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const requestedTab = initialTab as SettingsTab | undefined
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security"].includes(requestedTab) ? requestedTab : "base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
@@ -1604,11 +1700,10 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
||||
] : []),
|
||||
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
|
||||
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
|
||||
{ key: "about", label: "关于" },
|
||||
]
|
||||
React.useEffect(() => {
|
||||
if (tabs.some((tab) => tab.key === settingsTab)) return
|
||||
setSettingsTab(tabs[0]?.key || "about")
|
||||
setSettingsTab(tabs[0]?.key || "base")
|
||||
}, [settingsTab, tabs])
|
||||
return (
|
||||
<form key={formKey} onSubmit={(event) => { event.preventDefault(); if (canUpdateSettings) save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
|
||||
@@ -1851,9 +1946,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "about" && <AboutProjectCard />}
|
||||
|
||||
{settingsTab !== "about" && canUpdateSettings && <div className="flex justify-end">
|
||||
{canUpdateSettings && <div className="flex justify-end">
|
||||
<Button disabled={save.isPending || !settings}>{save.isPending ? "保存中..." : "保存设置"}</Button>
|
||||
</div>}
|
||||
</form>
|
||||
@@ -1956,76 +2049,6 @@ function queryErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
|
||||
}
|
||||
|
||||
function AboutProjectCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>关于</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<AboutRow label="版本">
|
||||
<SystemVersionDialog mode="inline" />
|
||||
</AboutRow>
|
||||
<AboutRow label="交流">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectRepositoryUrl} target="_blank" rel="noreferrer">
|
||||
<Github className="h-5 w-5" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={`${projectRepositoryUrl}/issues`} target="_blank" rel="noreferrer">
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
Issues
|
||||
</a>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectTelegramUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-5 w-5 text-sky-500" />
|
||||
Telegram 群组
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</AboutRow>
|
||||
<AboutRow label="支持">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectRepositoryUrl} target="_blank" rel="noreferrer">
|
||||
<Star className="h-5 w-5 text-yellow-500" />
|
||||
给项目点 Star
|
||||
</a>
|
||||
</Button>
|
||||
</AboutRow>
|
||||
<AboutRow label="帮助">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={`${projectRepositoryUrl}#readme`} target="_blank" rel="noreferrer">
|
||||
<BookOpen className="h-5 w-5 text-sky-500" />
|
||||
项目文档
|
||||
</a>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={`${projectRepositoryUrl}/blob/main/LICENSE`} target="_blank" rel="noreferrer">
|
||||
<Scale className="h-5 w-5 text-emerald-500" />
|
||||
开源协议
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</AboutRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-[4.5rem_minmax(0,1fr)] sm:items-center">
|
||||
<div className="font-medium text-muted-foreground">{label}:</div>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestSMTPDialog({ disabled }: { disabled?: boolean }) {
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
@@ -2206,8 +2229,8 @@ function sendAuditBadgeVariant(event?: string) {
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return <Card><CardContent className="flex items-center gap-3 p-4 sm:gap-4 sm:p-5"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground sm:h-10 sm:w-10">{icon}</div><div className="min-w-0"><div className="truncate text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div></CardContent></Card>
|
||||
function Stat({ icon, tone, label, value, detail }: { icon: React.ReactNode; tone: "primary" | "cyan" | "sky" | "violet"; label: string; value: React.ReactNode; detail: string }) {
|
||||
return <Card className="border-border/80"><CardContent className="flex min-h-[88px] items-center gap-3 p-3 !pt-3"><div className={cn("grid h-10 w-10 shrink-0 place-items-center rounded-lg [&>svg]:h-5 [&>svg]:w-5", tone === "primary" && "bg-primary/5 text-primary", tone === "cyan" && "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400", tone === "sky" && "bg-sky-500/10 text-sky-600 dark:text-sky-400", tone === "violet" && "bg-violet-500/10 text-violet-600 dark:text-violet-400")}>{icon}</div><div className="min-w-0"><div className="truncate text-xs font-medium">{label}</div><div className="truncate text-2xl font-semibold leading-7 tabular-nums">{value}</div><div className="truncate text-[11px] text-muted-foreground">{detail}</div></div></CardContent></Card>
|
||||
}
|
||||
function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg border p-4"><div className="text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div> }
|
||||
function Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
|
||||
@@ -2223,7 +2246,6 @@ function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onR
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className="flex items-center justify-between rounded-lg border p-3"><span className="font-medium">{domain.name}</span><Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "正常" : domain.dnsStatus}</Badge></div> }
|
||||
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
|
||||
|
||||
function UserMailboxCell({ user }: { user: AdminUser }) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { safeReturnPath } from "@/lib/navigation"
|
||||
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||
import { BrandMark } from "@/components/brand-mark"
|
||||
|
||||
export function LoginPage() {
|
||||
const me = useMe()
|
||||
@@ -43,7 +44,8 @@ export function LoginPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<div className="mb-7 flex items-center justify-center gap-3 text-center">
|
||||
<BrandMark className="size-11 [&>svg]:size-7" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||
|
||||
+165
-60
@@ -653,8 +653,8 @@ export function MailPage() {
|
||||
|
||||
const first = newMessages[0]
|
||||
const firstSender = senderDisplayName(first)
|
||||
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}`
|
||||
const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : `${firstSender}${first.snippet ? ` · ${first.snippet}` : ""}`
|
||||
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${messageSubject(first)}`
|
||||
const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : firstSender
|
||||
const openFirstMessage = () => {
|
||||
setMailView("folder")
|
||||
setFolder("Inbox")
|
||||
@@ -869,11 +869,12 @@ export function MailPage() {
|
||||
}
|
||||
function confirmDeleteMessage(message: MailMessage) {
|
||||
const permanent = message.folder === "Trash"
|
||||
const subject = messageSubject(message)
|
||||
setPendingConfirm({
|
||||
title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?",
|
||||
description: permanent
|
||||
? `邮件“${message.subject || "无主题"}”将被永久删除,且无法恢复。`
|
||||
: `邮件“${message.subject || "无主题"}”将移入已删除。`,
|
||||
? `邮件“${subject}”将被永久删除,且无法恢复。`
|
||||
: `邮件“${subject}”将移入已删除。`,
|
||||
confirmText: permanent ? "永久删除" : "移入已删除",
|
||||
onConfirm: () => del.mutate({ id: message.id, permanent }),
|
||||
})
|
||||
@@ -886,11 +887,11 @@ export function MailPage() {
|
||||
}
|
||||
function openReply(message: MailMessage) {
|
||||
if (!canSendMail) return
|
||||
openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) })
|
||||
openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(messageSubject(message), "Re:"), text: quoteMessage(message) })
|
||||
}
|
||||
function openForward(message: MailMessage) {
|
||||
if (!canSendMail) return
|
||||
openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) })
|
||||
openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(messageSubject(message), "Fwd:"), text: quoteMessage(message) })
|
||||
}
|
||||
async function openDraft(message: MailMessage) {
|
||||
if (!canManageDrafts) return
|
||||
@@ -1307,8 +1308,8 @@ export function MailPage() {
|
||||
setAdvancedSearchOpen(false)
|
||||
}
|
||||
const sidebarContent = (
|
||||
<Sidebar collapsible="none" className="h-full w-full border-r border-border bg-sidebar text-sidebar-foreground">
|
||||
<SidebarHeader className={cn("pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}>
|
||||
<Sidebar collapsible="none" className="h-full min-h-0 w-full overflow-hidden border-r border-border bg-sidebar text-sidebar-foreground">
|
||||
<SidebarHeader className={cn("shrink-0 pb-2 pt-3", sidebarCollapsed ? "px-2" : "px-3")}>
|
||||
<AccountHeader
|
||||
collapsed={sidebarCollapsed}
|
||||
name={me.data?.user.displayName || selectedMailbox?.address || "NewSzxcn"}
|
||||
@@ -1352,7 +1353,7 @@ export function MailPage() {
|
||||
</Button>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="px-1">
|
||||
<SidebarContent className="min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain px-1 pb-4">
|
||||
{!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />}
|
||||
<SidebarGroup>
|
||||
{!sidebarCollapsed && (
|
||||
@@ -1884,7 +1885,7 @@ export function MailPage() {
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[86vw] max-w-80 p-0 [&>button]:hidden" aria-describedby={undefined}>
|
||||
<SheetTitle className="sr-only">邮箱导航</SheetTitle>
|
||||
<div className="h-svh">{sidebarContent}</div>
|
||||
<div className="h-svh min-h-0 overflow-hidden">{sidebarContent}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold">{mailView === "label" && selectedLabel ? <Badge variant="outline" className="gap-1.5 rounded-md font-normal"><span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: labelDotColor(selectedLabel) }} />{selectedLabel.name}</Badge> : viewTitle}</div>
|
||||
@@ -1900,7 +1901,7 @@ export function MailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mail-shell-grid h-full min-h-0 w-full min-w-0 overflow-hidden">
|
||||
<div className="min-w-0">
|
||||
<div className="h-full min-h-0 min-w-0 overflow-hidden">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
<section className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
@@ -1942,6 +1943,7 @@ export function MailPage() {
|
||||
<CreateFolderDialog
|
||||
open={folderDialogOpen}
|
||||
pending={createFolder.isPending}
|
||||
scope={isAllMailboxSelected ? "全部邮箱" : selectedMailbox?.address || "当前邮箱"}
|
||||
onOpenChange={setFolderDialogOpen}
|
||||
onCreate={(payload) => createFolder.mutate(payload)}
|
||||
/>
|
||||
@@ -2825,7 +2827,7 @@ function contextMenuPosition(x: number, y: number) {
|
||||
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
|
||||
}
|
||||
|
||||
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
|
||||
function CreateFolderDialog({ open, pending, scope, onOpenChange, onCreate }: { open: boolean; pending: boolean; scope: string; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
|
||||
const [name, setName] = React.useState("")
|
||||
const [icon, setIcon] = React.useState("auto")
|
||||
const [uploadError, setUploadError] = React.useState("")
|
||||
@@ -2856,6 +2858,7 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-folder-name">文件夹名称</Label>
|
||||
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
|
||||
<p className="text-xs text-muted-foreground">创建位置:{scope}</p>
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">图标</legend>
|
||||
@@ -3190,7 +3193,7 @@ function CompactMessageDetail({
|
||||
<div className="w-full px-4 py-4 sm:px-8 sm:py-6">
|
||||
<div className="space-y-5 border-b pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{selected.subject}</h1>
|
||||
<h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{messageSubject(selected)}</h1>
|
||||
{canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
|
||||
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||
</Button>}
|
||||
@@ -3393,7 +3396,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
|
||||
<div onClick={onClick} onContextMenu={onContextMenu} className={cn(
|
||||
"cursor-pointer border-b border-l-2 px-3 py-2.5 text-[13px] transition-colors sm:grid sm:grid-cols-[28px_24px_minmax(112px,180px)_minmax(0,1fr)_86px_30px] sm:items-center sm:gap-2 sm:px-3 sm:py-2",
|
||||
message.isRead ? "border-l-transparent hover:bg-accent/50" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
|
||||
active && "bg-accent"
|
||||
active && "mail-selected-row"
|
||||
)}>
|
||||
<div className="flex gap-3 sm:contents">
|
||||
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" />
|
||||
@@ -3416,7 +3419,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0">
|
||||
<span className="truncate font-medium">{message.subject}</span>
|
||||
<span className="truncate font-medium">{messageSubject(message)}</span>
|
||||
<span className="hidden min-w-0 truncate text-muted-foreground sm:block">{message.snippet}</span>
|
||||
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal">已定时</Badge>}
|
||||
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)}
|
||||
@@ -3494,7 +3497,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}>
|
||||
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
||||
{darkMode ? <Sun className="h-3.5 w-3.5 text-amber-500" /> : <Moon className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -3620,6 +3623,10 @@ function senderDisplayName(message: MailMessage) {
|
||||
return displayNameFromAddress(message.from)
|
||||
}
|
||||
|
||||
function messageSubject(message: MailMessage) {
|
||||
return decodeMimeHeader(message.subject?.trim() || "") || "无主题"
|
||||
}
|
||||
|
||||
function displayNameFromAddress(value: string) {
|
||||
const text = decodeMimeHeader(value.trim())
|
||||
const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/)
|
||||
@@ -3830,8 +3837,8 @@ function MessageRow({
|
||||
return <div onClick={onClick} onContextMenu={onContextMenu} className={cn(
|
||||
"group cursor-pointer border-b border-l-2 px-3 py-2.5 transition-colors",
|
||||
message.isRead ? "border-l-transparent hover:bg-accent/60" : "border-l-primary bg-primary/5 font-semibold hover:bg-primary/10",
|
||||
active && "bg-accent",
|
||||
checked && "bg-accent/70"
|
||||
active && "mail-selected-row",
|
||||
checked && "mail-selected-row"
|
||||
)}>
|
||||
<div className="flex gap-2.5">
|
||||
<Checkbox
|
||||
@@ -3875,7 +3882,7 @@ function MessageRow({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[13px] text-foreground">{message.subject || "无主题"}</span>
|
||||
<span className="min-w-0 truncate text-[13px] text-foreground">{messageSubject(message)}</span>
|
||||
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal">已定时</Badge>}
|
||||
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)}
|
||||
{hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>}
|
||||
@@ -3916,6 +3923,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "")
|
||||
const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
|
||||
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null)
|
||||
const [closing, setClosing] = React.useState(false)
|
||||
const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false)
|
||||
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
|
||||
const sendStartedRef = React.useRef(false)
|
||||
@@ -4031,7 +4039,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
}, [files])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
|
||||
if (!open || closing || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
|
||||
const payloadKey = JSON.stringify({ ...composePayload, draftId })
|
||||
if (payloadKey === lastSavedPayloadRef.current) return
|
||||
const timer = window.setTimeout(async () => {
|
||||
@@ -4052,7 +4060,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
}
|
||||
}, 5000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [open, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
|
||||
}, [open, closing, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
|
||||
|
||||
function buildSendWarnings(attachmentsCount: number) {
|
||||
const warnings: string[] = []
|
||||
@@ -4104,6 +4112,10 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
const to = splitEmails(toValue)
|
||||
const cc = showCc ? splitEmails(ccValue) : []
|
||||
const bcc = showBcc ? splitEmails(bccValue) : []
|
||||
if (to.length === 0) {
|
||||
toast({ title: "请填写收件人" })
|
||||
return
|
||||
}
|
||||
const text = body.text
|
||||
const html = body.html || plainTextToHtml(text)
|
||||
const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
|
||||
@@ -4139,11 +4151,18 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
}
|
||||
if (!attachmentsWithinLimit()) return
|
||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||
const to = splitEmails(toValue)
|
||||
const cc = showCc ? splitEmails(ccValue) : []
|
||||
const bcc = showBcc ? splitEmails(bccValue) : []
|
||||
if (to.length === 0) {
|
||||
toast({ title: "请填写收件人" })
|
||||
return
|
||||
}
|
||||
const payload: SendPayload & { draftId?: string; sendAt: string } = {
|
||||
mailboxId: senderMailbox.id,
|
||||
to: splitEmails(toValue),
|
||||
cc: showCc ? splitEmails(ccValue) : [],
|
||||
bcc: showBcc ? splitEmails(bccValue) : [],
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: subjectValue,
|
||||
text: body.text,
|
||||
html: body.html || plainTextToHtml(body.text),
|
||||
@@ -4163,15 +4182,45 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function closeCompose() {
|
||||
if (closing) return
|
||||
if (!canManageDrafts || sendStartedRef.current || !hasDraftContent) {
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
setClosing(true)
|
||||
setDraftStatus("saving")
|
||||
try {
|
||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||
const payload: DraftPayload = { ...composePayload, attachments }
|
||||
const saved = await api.saveDraft(payload, draftId || undefined)
|
||||
setDraftId(saved.id)
|
||||
lastSavedPayloadRef.current = JSON.stringify({ ...payload, draftId: saved.id })
|
||||
setLastSavedAt(new Date())
|
||||
setDraftStatus("saved")
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["messages"] }),
|
||||
qc.invalidateQueries({ queryKey: ["folders"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||
])
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
setDraftStatus("error")
|
||||
toast({ title: "草稿保存失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||
} finally {
|
||||
setClosing(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (nextOpen) onOpenChange(true); else void closeCompose() }}>
|
||||
<DialogContent
|
||||
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(92vw,72rem)]"
|
||||
className="flex h-svh w-screen max-w-none overflow-hidden p-0 sm:h-[min(88vh,48rem)] sm:w-[min(92vw,56rem)]"
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<form key={draft?.key || "new"} className="flex min-h-0 flex-1 flex-col sm:max-h-[90vh]" onSubmit={submit}>
|
||||
<DialogHeader className="border-b px-4 py-3 text-left sm:px-6 sm:py-4">
|
||||
<form key={draft?.key || "new"} className="flex min-h-0 min-w-0 flex-1 flex-col" onSubmit={submit}>
|
||||
<DialogHeader className="border-b bg-muted/20 px-4 py-3 text-left sm:px-5">
|
||||
<DialogTitle className="flex min-w-0 flex-col gap-1 pr-8 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:pr-6">
|
||||
<span>{draftId ? "编辑草稿" : "写信"}</span>
|
||||
<span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}>
|
||||
@@ -4219,7 +4268,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
<Input name="bcc" placeholder="bcc@example.com" value={bccValue} onChange={(event) => setBccValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
||||
</ComposeField>
|
||||
)}
|
||||
<ComposeField label="主 题">
|
||||
<ComposeField label="主题">
|
||||
<Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
||||
</ComposeField>
|
||||
{defaultSignature.isError && (
|
||||
@@ -4241,10 +4290,25 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="grid grid-cols-3 gap-2 border-t bg-background px-4 py-3 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4">
|
||||
<Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
{canSchedule && <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !senderMailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" />定时</Button>}
|
||||
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>}
|
||||
<DialogFooter className="flex flex-row items-center justify-between gap-3 border-t bg-muted/15 px-4 py-3 sm:px-5">
|
||||
{canSend && (
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Button className={cn("compose-send-button min-h-10 px-4", canSchedule && "rounded-r-none")} disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>
|
||||
{canSchedule && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="icon" className="compose-send-button h-10 w-9 shrink-0 rounded-l-none border-l border-white/30 px-0" title="发送选项" aria-label="发送选项" disabled={send.isPending || scheduleSend.isPending || !senderMailbox}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top" sideOffset={8} collisionPadding={12} className="w-[120px] min-w-[120px]">
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => setScheduleDialogOpen(true)}><Clock3 className="h-4 w-4" />定时发送</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="ghost" className="min-h-10 px-3" disabled={closing} onClick={() => { void closeCompose() }}>{closing ? "保存中..." : "取消"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
|
||||
@@ -4264,8 +4328,8 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
|
||||
function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-14 flex-col gap-2 border-b px-4 py-2 sm:flex-row sm:items-center sm:px-6">
|
||||
<Label className="shrink-0 text-base font-normal text-foreground sm:w-20">{label}</Label>
|
||||
<div className="flex min-h-12 flex-col gap-1 border-b px-4 py-1.5 transition-colors focus-within:bg-muted/20 sm:flex-row sm:items-center sm:gap-2 sm:px-5">
|
||||
<Label className="shrink-0 text-sm font-normal text-muted-foreground sm:w-16">{label}</Label>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{children}
|
||||
{action}
|
||||
@@ -4337,11 +4401,17 @@ type InsertDialogValue = { url: string; text: string; alt: string }
|
||||
const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"]
|
||||
const composerFontSizeOptions = [
|
||||
["2", "小号"],
|
||||
["3", "正文"],
|
||||
["3", "标准"],
|
||||
["4", "中号"],
|
||||
["5", "大号"],
|
||||
] as const
|
||||
const composerFontSizeValueByKey: Record<string, string> = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" }
|
||||
const composerParagraphOptions: ReadonlyArray<{ key: string; label: string; level?: 1 | 2 | 3 }> = [
|
||||
{ key: "paragraph", label: "正文" },
|
||||
{ key: "heading-1", label: "标题 1", level: 1 as const },
|
||||
{ key: "heading-2", label: "标题 2", level: 2 as const },
|
||||
{ key: "heading-3", label: "标题 3", level: 3 as const },
|
||||
] as const
|
||||
const composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const
|
||||
const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const
|
||||
const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"]
|
||||
@@ -4383,7 +4453,15 @@ function fontLabel(value: string) {
|
||||
|
||||
function fontSizeLabel(value: string) {
|
||||
const normalized = normalizeFontSize(value) || "3"
|
||||
return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "正文"
|
||||
return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "标准"
|
||||
}
|
||||
|
||||
function paragraphStyleLabel(editor?: Editor | null) {
|
||||
if (!editor) return "正文"
|
||||
for (const option of composerParagraphOptions) {
|
||||
if (option.level && editor.isActive("heading", { level: option.level })) return option.label
|
||||
}
|
||||
return "正文"
|
||||
}
|
||||
|
||||
function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) {
|
||||
@@ -4509,8 +4587,10 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
content: composerInitialHtml(defaultValue, defaultHtml),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "mail-html min-h-[240px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[280px] sm:px-6 sm:py-5",
|
||||
class: "mail-html min-h-[220px] min-w-0 flex-1 overflow-y-auto px-4 py-4 text-base leading-7 outline-none sm:min-h-[260px] sm:px-5",
|
||||
"aria-label": "正文",
|
||||
spellcheck: "true",
|
||||
autocorrect: "on",
|
||||
},
|
||||
handlePaste(view, event) {
|
||||
const clipboard = event.clipboardData
|
||||
@@ -4564,6 +4644,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3"
|
||||
const activeColor = textStyleAttributes?.color || ""
|
||||
const activeHighlight = textStyleAttributes?.backgroundColor || ""
|
||||
const characterCount = editor?.getText().replace(/\s/g, "").length || 0
|
||||
void selectionVersion
|
||||
|
||||
function applyFont(font: string) {
|
||||
@@ -4631,9 +4712,9 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[330px] flex-1 flex-col bg-background sm:min-h-[420px]">
|
||||
<div className="flex min-h-[300px] min-w-0 flex-1 flex-col bg-background sm:min-h-[360px]">
|
||||
<Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} />
|
||||
<div className="flex min-h-11 flex-wrap items-center gap-1 overflow-visible border-b px-3 py-2 sm:px-6">
|
||||
<div className="flex min-h-11 flex-wrap items-center gap-1 overflow-visible border-b px-3 py-2 sm:px-5">
|
||||
<ToolbarButton label="撤销" disabled={!editor?.can().undo()} onClick={() => editor?.chain().focus().undo().run()}><Undo2 className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="重做" disabled={!editor?.can().redo()} onClick={() => editor?.chain().focus().redo().run()}><Redo2 className="h-4 w-4" /></ToolbarButton>
|
||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||
@@ -4650,7 +4731,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />分隔线</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">附件 {maxAttachmentText}</span>
|
||||
<span className="px-1 text-xs text-muted-foreground" title={`单个附件上限 ${maxAttachmentText}`}>附件上限 {maxAttachmentText}</span>
|
||||
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
|
||||
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -4675,9 +4756,24 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
</div>
|
||||
</div>
|
||||
{formatOpen && (
|
||||
<div className="flex min-h-14 flex-wrap items-center gap-1 overflow-visible border-b bg-muted/40 px-3 py-2 sm:px-6">
|
||||
<div className="flex min-h-14 flex-wrap items-center gap-1 overflow-visible border-b bg-muted/40 px-3 py-2 sm:px-5">
|
||||
<ToolbarButton label="清除格式" disabled={!editor} onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser className="h-4 w-4" /></ToolbarButton>
|
||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[76px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent", paragraphStyleLabel(editor) !== "正文" && "border-primary/35 bg-primary/10 text-primary")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
|
||||
{paragraphStyleLabel(editor)}<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{composerParagraphOptions.map((option) => (
|
||||
<DropdownMenuItem key={option.key} className={composerMenuItemClass} onSelect={() => option.level ? editor?.chain().focus().toggleHeading({ level: option.level }).run() : editor?.chain().focus().setParagraph().run()}>
|
||||
<Check className={cn("h-4 w-4", paragraphStyleLabel(editor) === option.label ? "opacity-100" : "opacity-0")} />
|
||||
<span className={cn(option.level === 1 && "text-lg font-semibold", option.level === 2 && "text-base font-semibold", option.level === 3 && "text-sm font-semibold")}>{option.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[112px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", activeFont && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
|
||||
@@ -4708,7 +4804,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
<ToolbarButton label="加粗" active={editor?.isActive("bold")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="斜体" active={editor?.isActive("italic")} disabled={!editor} onClick={() => editor?.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="下划线" active={editor?.isActive("underline")} disabled={!editor} onClick={() => editor?.chain().focus().toggleUnderline().run()}><Underline className="h-4 w-4" /></ToolbarButton>
|
||||
@@ -4743,30 +4839,39 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
<ToolbarButton label="无序列表" active={editor?.isActive("bulletList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="有序列表" active={editor?.isActive("orderedList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="减少缩进" disabled={!editor?.can().liftListItem("listItem")} onClick={() => editor?.chain().focus().liftListItem("listItem").run()}><IndentDecrease className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="增加缩进" disabled={!editor?.can().sinkListItem("listItem")} onClick={() => editor?.chain().focus().sinkListItem("listItem").run()}><IndentIncrease className="h-4 w-4" /></ToolbarButton>
|
||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||
<ToolbarButton label="左对齐" active={editor?.isActive({ textAlign: "left" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("left").run()}><AlignLeft className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="居中" active={editor?.isActive({ textAlign: "center" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("center").run()}><AlignCenter className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="右对齐" active={editor?.isActive({ textAlign: "right" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("right").run()}><AlignRight className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="引用" active={editor?.isActive("blockquote")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToolbarButton>
|
||||
<ToolbarButton label="代码块" active={editor?.isActive("codeBlock")} disabled={!editor} onClick={() => editor?.chain().focus().toggleCodeBlock().run()}><Code2 className="h-4 w-4" /></ToolbarButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" title="更多格式" aria-label="更多格式" onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem className={composerMenuItemClass} disabled={!editor?.can().liftListItem("listItem")} onSelect={() => editor?.chain().focus().liftListItem("listItem").run()}><IndentDecrease className="h-4 w-4" />减少缩进</DropdownMenuItem>
|
||||
<DropdownMenuItem className={composerMenuItemClass} disabled={!editor?.can().sinkListItem("listItem")} onSelect={() => editor?.chain().focus().sinkListItem("listItem").run()}><IndentIncrease className="h-4 w-4" />增加缩进</DropdownMenuItem>
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("left").run()}><AlignLeft className="h-4 w-4" />左对齐</DropdownMenuItem>
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("center").run()}><AlignCenter className="h-4 w-4" />居中</DropdownMenuItem>
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setTextAlign("right").run()}><AlignRight className="h-4 w-4" />右对齐</DropdownMenuItem>
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" />引用</DropdownMenuItem>
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().toggleCodeBlock().run()}><Code2 className="h-4 w-4" />代码块</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="ml-auto whitespace-nowrap px-1 text-xs tabular-nums text-muted-foreground" aria-live="polite">{characterCount} 字</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
"composer-editor relative flex min-h-[240px] flex-1 border-b focus-within:bg-card/40 sm:min-h-[280px]",
|
||||
"[&_.ProseMirror]:min-h-[240px] [&_.ProseMirror]:w-full [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-4 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none sm:[&_.ProseMirror]:min-h-[280px] sm:[&_.ProseMirror]:px-6 sm:[&_.ProseMirror]:py-5",
|
||||
"composer-editor relative flex min-h-[220px] min-w-0 flex-1 overflow-hidden border-b focus-within:bg-card/40 sm:min-h-[260px]",
|
||||
"[&_.ProseMirror]:min-h-[220px] [&_.ProseMirror]:min-w-0 [&_.ProseMirror]:w-full [&_.ProseMirror]:max-w-full [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-x-hidden [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-4 [&_.ProseMirror]:py-4 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none [&_.ProseMirror]:[overflow-wrap:anywhere] sm:[&_.ProseMirror]:min-h-[260px] sm:[&_.ProseMirror]:px-5",
|
||||
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]",
|
||||
"[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3",
|
||||
"[&_.ProseMirror_a]:break-all [&_.ProseMirror_p]:max-w-full [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:max-w-full [&_.ProseMirror_pre]:whitespace-pre-wrap [&_.ProseMirror_pre]:break-words [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3",
|
||||
empty && "bg-background"
|
||||
)}>
|
||||
<EditorContent editor={editor} className="flex min-h-0 flex-1" />
|
||||
<EditorContent editor={editor} className="flex min-h-0 min-w-0 flex-1 overflow-hidden" />
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="border-t px-4 py-3 sm:px-6">
|
||||
<div className="border-t px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((file, index) => (
|
||||
<Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal">
|
||||
@@ -4788,7 +4893,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
<DialogHeader>
|
||||
<DialogTitle>邮件预览</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mail-html max-h-[60vh] overflow-y-auto rounded-md border bg-background p-5 text-sm leading-7" dangerouslySetInnerHTML={{ __html: sanitizeComposerHtml(editor?.getHTML() || "") || "<p></p>" }} />
|
||||
<div className="mail-html max-h-[60vh] max-w-full overflow-x-hidden overflow-y-auto rounded-md border bg-background p-5 text-sm leading-7 [overflow-wrap:anywhere] [&_a]:break-all [&_pre]:max-w-full [&_pre]:whitespace-pre-wrap [&_pre]:break-words" dangerouslySetInnerHTML={{ __html: sanitizeComposerHtml(editor?.getHTML() || "") || "<p></p>" }} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
@@ -5231,7 +5336,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas
|
||||
function quoteMessage(message: MailMessage) {
|
||||
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
|
||||
const quote = body.split("\n").map((line) => `> ${line}`).join("\n")
|
||||
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
|
||||
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${messageSubject(message)}\n\n${quote}`
|
||||
}
|
||||
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
|
||||
function attachmentLimitBytes(limits?: PermissionLimits) {
|
||||
|
||||
@@ -356,11 +356,11 @@ export function ProfilePage() {
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm transition-colors",
|
||||
tab === key ? "bg-muted font-semibold text-foreground" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
tab === key ? "bg-[hsl(var(--sidebar-active))] font-semibold text-[hsl(var(--sidebar-active-foreground))]" : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
<span className={cn("text-muted-foreground [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-[1.8]", tab === key && "text-foreground/70")}>{tabs[key].icon}</span>
|
||||
<span className={cn("text-muted-foreground [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-[1.8]", tab === key && "text-[hsl(var(--sidebar-active-foreground))]")}>{tabs[key].icon}</span>
|
||||
<span className="truncate">{tabs[key].label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -377,7 +377,7 @@ export function ProfilePage() {
|
||||
</div>
|
||||
</nav>
|
||||
<div className="border-t p-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-9 w-full justify-start gap-2 px-3 text-destructive hover:text-destructive" onClick={logout}>
|
||||
<Button type="button" variant="outline" size="sm" className="h-9 w-full justify-start gap-2 border-destructive/35 px-3 text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>退出登录</span>
|
||||
</Button>
|
||||
@@ -393,7 +393,7 @@ export function ProfilePage() {
|
||||
<StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
|
||||
</div>
|
||||
: tab === "apiTokens"
|
||||
? <Button asChild variant="outline" size="sm" className="h-8 px-3 text-xs"><a href="https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/API.md" target="_blank" rel="noreferrer"><BookOpen className="h-4 w-4" />API 文档</a></Button>
|
||||
? <Button asChild variant="outline" size="sm" className="h-8 px-3 text-xs"><a href="https://gitea.xzys.me/szx/NewSzxcn-Email/src/branch/main/docs/API.md" target="_blank" rel="noreferrer"><BookOpen className="h-4 w-4" />API 文档</a></Button>
|
||||
: undefined
|
||||
|
||||
return (
|
||||
@@ -563,7 +563,7 @@ function SettingsPageHeader({ title, subtitle, action, activeTab, onAccountTabCh
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-[38px] shrink-0 border-b-2 px-4 text-sm font-medium transition-colors",
|
||||
activeTab === item.key ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
activeTab === item.key ? "border-[hsl(var(--sidebar-active-foreground))] bg-[hsl(var(--sidebar-active))] text-[hsl(var(--sidebar-active-foreground))]" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onAccountTabChange(item.key)}
|
||||
>
|
||||
@@ -2868,7 +2868,7 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
|
||||
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{darkMode ? <Sun className="h-4 w-4 text-amber-500" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { TurnstileBox } from "@/components/turnstile-box"
|
||||
import { validatePasswordConfirm } from "@/lib/validation"
|
||||
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||
import { BrandMark } from "@/components/brand-mark"
|
||||
|
||||
export function RegisterPage() {
|
||||
const me = useMe()
|
||||
@@ -63,7 +64,8 @@ export function RegisterPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<div className="mb-7 flex items-center justify-center gap-3 text-center">
|
||||
<BrandMark className="size-11 [&>svg]:size-7" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
推荐直接使用仓库根目录的管理脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
后续操作:
|
||||
|
||||
@@ -22,7 +22,7 @@ Telegram 消息包含邮局域名、服务器 IP、系统版本、已有域名
|
||||
确认文件已经上传后,首次执行官方脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
脚本显示“尚未安装”管理菜单后,输入 `2`,选择“备份恢复”。在恢复完成后,以后需要管理系统时才使用 `ns` 打开管理菜单。
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
建议使用 Debian 或 Ubuntu,并提前准备一个已经解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
bash <(curl -fsSL https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
安装脚本会依次询问防火墙配置和邮件服务器域名,自动检测邮箱地址域名,再让你选择默认 `admin` 前缀或自定义管理员邮箱前缀,最后输入密码并选择 Web 部署方式。例如输入服务器域名 `mail.newszxcn.com`,确认检测结果 `@newszxcn.com`,选择 `1. 使用默认前缀 admin` 会创建 `admin@newszxcn.com`;选择 `2. 自定义管理员邮箱前缀` 后才需要输入邮箱账号前缀。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
|
||||
@@ -170,4 +170,4 @@ sudo newszxcn-email reset-2fa
|
||||
- [项目说明](../README.md)
|
||||
- [Docker 部署说明](../deploy/README.md)
|
||||
- [API 文档](API.md)
|
||||
- [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases)
|
||||
- [版本发布](https://gitea.xzys.me/szx/NewSzxcn-Email/releases)
|
||||
|
||||
+10
-3
@@ -1,9 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
REPOSITORY="zxyszx/NewSzxcn-Email"
|
||||
RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
|
||||
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
|
||||
DEFAULT_RAW_BASE="https://gitea.xzys.me/szx/NewSzxcn-Email/raw/branch/main"
|
||||
SOURCE_URL_FILE="${INSTALL_DIR}/.source-url"
|
||||
RAW_BASE="${LANQIN_RAW_BASE:-}"
|
||||
if [[ -z "${RAW_BASE}" && -s "${SOURCE_URL_FILE}" ]]; then
|
||||
RAW_BASE="$(tr -d '\r\n' < "${SOURCE_URL_FILE}")"
|
||||
fi
|
||||
RAW_BASE="${RAW_BASE:-${DEFAULT_RAW_BASE}}"
|
||||
COMMAND="${1:-menu}"
|
||||
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
|
||||
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
|
||||
@@ -135,6 +140,8 @@ apply_staged_assets() {
|
||||
install -m 0644 "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.env.example" || return 1
|
||||
install -m 0755 "${INSTALL_DIR}/.install.sh.new" "${CLI_PATH}.new" || return 1
|
||||
mv "${CLI_PATH}.new" "${CLI_PATH}" || return 1
|
||||
printf '%s\n' "${RAW_BASE}" > "${SOURCE_URL_FILE}" || return 1
|
||||
chmod 0644 "${SOURCE_URL_FILE}" || return 1
|
||||
rm -f "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.install.sh.new"
|
||||
}
|
||||
|
||||
@@ -1524,7 +1531,7 @@ SSL 证书:有效期至 ${certificate_expiry}
|
||||
重置管理员密码:newszxcn-email reset-password
|
||||
|
||||
公开教程:
|
||||
https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/GUIDE.md
|
||||
https://gitea.xzys.me/szx/NewSzxcn-Email/src/branch/main/docs/GUIDE.md
|
||||
EOF
|
||||
install -m 0600 "${tmp}" "${GUIDE_FILE}"
|
||||
rm -f "${tmp}"
|
||||
|
||||
@@ -126,6 +126,24 @@ test_compose_configuration() {
|
||||
grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing"
|
||||
}
|
||||
|
||||
test_source_url_persistence() (
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
SOURCE_URL_FILE="${INSTALL_DIR}/.source-url"
|
||||
CLI_PATH="${temp_dir}/newszxcn-email"
|
||||
RAW_BASE="https://gitea.example.test/szx/NewSzxcn-Email/raw/branch/main"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
printf 'services: {}\n' > "${INSTALL_DIR}/.docker-compose.yml.new"
|
||||
printf 'LANQIN_IMAGE=test\n' > "${INSTALL_DIR}/.env.example.new"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "${INSTALL_DIR}/.install.sh.new"
|
||||
|
||||
apply_staged_assets
|
||||
|
||||
assert_eq "${RAW_BASE}" "$(cat "${SOURCE_URL_FILE}")" "persisted installer source URL"
|
||||
[[ -x "${CLI_PATH}" ]] || fail_test "installed CLI is not executable"
|
||||
)
|
||||
|
||||
test_legacy_configuration_is_preserved() {
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
@@ -835,6 +853,7 @@ test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "fals
|
||||
test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
|
||||
test_nginx_configuration
|
||||
test_compose_configuration
|
||||
test_source_url_persistence
|
||||
test_legacy_configuration_is_preserved
|
||||
test_menu_choice
|
||||
test_menu_rendering
|
||||
|
||||
Reference in New Issue
Block a user