Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3caee11e38 | |||
| 2dd3647923 | |||
| ccb8ce01d9 | |||
| 4e3b69608f | |||
| 1dbc33b0dc | |||
| 48a1d53133 | |||
| ff5578368a | |||
| fc3a3462cf | |||
| 9a572e0100 | |||
| c92140c9cc |
@@ -0,0 +1,9 @@
|
|||||||
|
- 修复邮箱侧栏切换目录后,紧凑列表顶部仍可能显示上一个目录名称的问题;收件箱、草稿箱、已发送、已归档、已删除、星标邮件、垃圾邮件、自定义文件夹和标签现在会与当前列表保持一致。
|
||||||
|
- 切换邮箱目录时按当前视图重新生成列表区域,避免星标邮件等旧标题或旧操作状态残留到其他目录。
|
||||||
|
- 修复“已删除”目录批量删除仍执行“移入已删除”的问题;现在会永久删除所选邮件,并在操作前显示不可恢复的二次确认。
|
||||||
|
- 区分普通目录与“已删除”的删除语义:普通目录显示“移入已删除”,已删除目录显示“永久删除”。
|
||||||
|
- 统一批量工具栏、邮件详情、右键菜单和列表快捷按钮的删除名称、确认文案与完成提示,避免显示“已移动”但实际执行删除等文字不一致。
|
||||||
|
- 优化批量归档、移回收件箱、移入垃圾邮件和移入已删除后的提示,准确显示目标目录及处理数量。
|
||||||
|
- 后台邮箱管理中将账号默认邮箱固定置顶,其余子邮箱继续按 A-Z / 0-9 排序,方便快速识别账号主邮箱。
|
||||||
|
- 修正邮箱管理表头与账号行的网格结构,“权限管理”和“子邮箱”列标题及内容统一居中对齐。
|
||||||
|
- 保留默认邮箱不可停用、不可删除保护;普通子邮箱继续支持启用、停用和二次确认删除。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- 邮件正文新增“自动翻译”开关并默认开启;打开邮件时仅在检测到正文语言与当前界面语言明显不同时自动翻译,避免中文邮件产生无意义请求。
|
||||||
|
- 自动翻译开关会保存在当前浏览器中,关闭后继续显示原文,下次访问仍沿用用户选择。
|
||||||
|
- 缓存同一封邮件、同一目标语言的翻译结果;返回列表后再次打开邮件可直接显示译文,减少重复等待和翻译请求。
|
||||||
|
- 保留“显示原文”“显示译文”和“重新翻译”操作;重新翻译会主动刷新缓存中的译文。
|
||||||
|
- 优化服务端翻译流程,纯文本正文与 HTML 正文改为并行处理,HTML 文本节点使用受控并发翻译,复杂排版邮件的翻译速度更快。
|
||||||
|
- 翻译后的邮件继续保留原有 HTML 结构、图片和样式,并跳过代码、预格式文本、脚本及样式内容。
|
||||||
|
- 增加 HTML 翻译测试和并发检测,确保排版结构不被破坏且没有数据竞争。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
- 后台新增“备份与恢复”,可创建、校验、下载、删除完整加密备份;备份包含账号、邮件、附件、Maildir、DKIM、证书和部署配置。
|
||||||
|
- 备份使用 AES-256-CBC、PBKDF2 和 SHA-256 校验;支持自行输入或生成 24 位恢复密码,并提供显示、复制和本地密码文件下载。
|
||||||
|
- 新增 3、5、7、30 天及自定义周期的定时备份,可独立选择本地保留、Telegram 推送和 Google 云端硬盘。
|
||||||
|
- Telegram 备份复用系统已绑定机器人,可沿用邮件通知接收方,也可自动查询多个群组并选择独立备份群组;邮件通知与备份推送互不干扰。
|
||||||
|
- 新增 Google 云端硬盘 OAuth 配置、加密令牌保存、专用备份目录、手动上传和定时上传。
|
||||||
|
- 安装脚本新增未安装状态管理菜单和“备份恢复”,自动扫描 `/root/` 下的多份备份并按时间排序,支持输入序号恢复。
|
||||||
|
- 恢复流程增加压缩包路径、符号链接、特殊文件和 SQLite 完整性校验;失败时清理不完整安装并保留原始加密备份。
|
||||||
|
- 优化备份页面的桌面与手机布局、状态对齐、配置弹窗和本地备份列表;修复未配置 Telegram 时本地备份被误报推送失败的问题。
|
||||||
|
- 修复后台邮箱管理中失联归属账号可能产生重复列表标识的问题,并将同一归属账号的邮箱重新聚合显示。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.31...v1.2.32](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.31...v1.2.32)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
- 修复 `v1.2.32` 在线更新只替换镜像、未同步宿主机 Compose 文件时,“创建备份”按钮持续灰色的问题。
|
||||||
|
- 完整备份组件改为随 API 和一体化镜像提供;旧服务器升级后可直接使用现有 `/data` 持久化目录创建备份,无需手动修改部署文件。
|
||||||
|
- 备份会根据当前容器运行配置生成可恢复的 `.env`,并过滤只适用于旧容器内部的更新和备份路径变量。
|
||||||
|
- 服务器 IP 改为根据邮局主机名的公网 DNS 自动检测,移除私人 IP 示例和手动填写项,支持一键重新检测。
|
||||||
|
- Telegram 备份报告实时使用自动检测到的服务器 IP;检测失败时明确显示“未检测到”,不保存或暴露固定地址。
|
||||||
|
- Google Cloud OAuth 回调地址改为单行只读输入框并增加复制按钮,修复长地址断行影响查看和复制的问题。
|
||||||
|
- 优化备份组件缺失提示,并完成桌面、手机页面溢出检查以及备份、恢复、安装、回滚和 DKIM 回归测试。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.32...v1.2.33](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.32...v1.2.33)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
- 手动备份与定时备份统一使用同一个恢复密码,避免每次创建备份时再次输入不同密码造成混淆。
|
||||||
|
- 已保存备份密码时,点击“创建备份”不再显示第二套密码输入框,直接使用系统安全保存的密码。
|
||||||
|
- 首次创建备份且尚未设置密码时,仍要求输入并二次确认;首次密码会保存为后续手动与定时备份的统一恢复密码。
|
||||||
|
- 定时备份页面精简为“恢复密码”摘要,仅显示首尾字符掩码,例如 `A••••••••9`;设置或更换密码时使用独立弹窗,不再挤占主页面。
|
||||||
|
- 密码更新使用独立接口,不会连带修改尚未保存的备份周期、Telegram 或 Google 云端硬盘设置。
|
||||||
|
- 页面只接收密码首尾掩码,不会返回完整恢复密码;更换密码时仍必须重新输入并确认。
|
||||||
|
- 增加统一密码、密码掩码、已保存密码手动备份及首次并发创建的后端保护与回归测试。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.33...v1.2.34](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.33...v1.2.34)
|
||||||
@@ -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)
|
||||||
@@ -48,6 +48,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
|||||||
|
|
||||||
## 更新与回滚
|
## 更新与回滚
|
||||||
|
|
||||||
|
完整加密备份、Telegram 推送和新服务器恢复流程见 [备份与灾难恢复](docs/BACKUP_RESTORE.md)。
|
||||||
|
|
||||||
### 后台页面更新
|
### 后台页面更新
|
||||||
|
|
||||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type App struct {
|
|||||||
telegramPairMu sync.Mutex
|
telegramPairMu sync.Mutex
|
||||||
telegramPairs map[string]telegramPairing
|
telegramPairs map[string]telegramPairing
|
||||||
telegramDeliveryMu sync.Mutex
|
telegramDeliveryMu sync.Mutex
|
||||||
|
backupMu sync.Mutex
|
||||||
|
backupJob *backupJob
|
||||||
|
backupTransfers map[string]*backupTransfer
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -81,7 +84,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
}
|
}
|
||||||
db.SetMaxOpenConns(1)
|
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
|
a.externalIMAP = a
|
||||||
if err := a.configureSQLite(context.Background()); err != nil {
|
if err := a.configureSQLite(context.Background()); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
@@ -130,6 +133,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||||
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
|
||||||
|
a.startWorker(func() { a.backupScheduleWorker(workerCtx) })
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
stopTestWorkers(a)
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("manual backup mismatch code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
|
||||||
|
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"ok":true,"result":[`+
|
||||||
|
`{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
|
||||||
|
`{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+
|
||||||
|
`{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
|
||||||
|
`{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`)
|
||||||
|
}))
|
||||||
|
defer telegramServer.Close()
|
||||||
|
a := newTestApp(t)
|
||||||
|
stopTestWorkers(a)
|
||||||
|
a.telegramURL = telegramServer.URL
|
||||||
|
groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" {
|
||||||
|
t.Fatalf("unexpected groups: %+v", groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if size != int64(len("encrypted backup")) {
|
||||||
|
t.Fatalf("upload size = %d", size)
|
||||||
|
}
|
||||||
|
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(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true,
|
||||||
|
})
|
||||||
|
if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
|
||||||
|
t.Fatal("backup password encryption succeeded without a deployment secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordValidation(t *testing.T) {
|
||||||
|
for _, valid := range []string{"12345678", "Restore Password 123!"} {
|
||||||
|
if !validBackupPassword(valid) {
|
||||||
|
t.Errorf("valid password rejected: %q", valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
|
||||||
|
if validBackupPassword(invalid) {
|
||||||
|
t.Errorf("invalid password accepted: %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordHint(t *testing.T) {
|
||||||
|
if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" {
|
||||||
|
t.Fatalf("password hint = %q", got)
|
||||||
|
}
|
||||||
|
if got := backupPasswordHint("ab"); got != "ab" {
|
||||||
|
t.Fatalf("two-character password hint = %q", got)
|
||||||
|
}
|
||||||
|
if got := backupPasswordHint(""); got != "" {
|
||||||
|
t.Fatalf("empty password hint = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSavedBackupPasswordAndHint(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
ciphertext, err := a.encryptBackupPassword("A23456789Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "A23456789Z" {
|
||||||
|
t.Fatalf("saved password = %q, %v", password, err)
|
||||||
|
}
|
||||||
|
schedule, err := a.loadBackupSchedule(context.Background())
|
||||||
|
if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" {
|
||||||
|
t.Fatalf("schedule password state = %+v, %v", schedule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"backupScheduleEnabled": "true",
|
||||||
|
"backupScheduleDays": "30",
|
||||||
|
"backupTelegramMode": "custom",
|
||||||
|
"backupTelegramChatId": "-1001234567890",
|
||||||
|
"backupGoogleFolderName": "Existing Backups",
|
||||||
|
} {
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("password update code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
if response["passwordHint"] != "N••••••••••9" {
|
||||||
|
t.Fatalf("password hint = %v", response["passwordHint"])
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "NewSharedPassword9" {
|
||||||
|
t.Fatalf("saved password = %q, %v", password, err)
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"backupScheduleEnabled": "true",
|
||||||
|
"backupScheduleDays": "30",
|
||||||
|
"backupTelegramMode": "custom",
|
||||||
|
"backupTelegramChatId": "-1001234567890",
|
||||||
|
"backupGoogleFolderName": "Existing Backups",
|
||||||
|
} {
|
||||||
|
var got string
|
||||||
|
if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want {
|
||||||
|
t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualBackupReusesSavedPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
deployDir := filepath.Join(dir, "deploy")
|
||||||
|
if err := os.MkdirAll(deployDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir,
|
||||||
|
BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(a.Router())
|
||||||
|
defer server.Close()
|
||||||
|
admin := &testClient{t: t, server: server}
|
||||||
|
var response map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
response = nil
|
||||||
|
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted {
|
||||||
|
t.Fatalf("manual backup code=%d body=%v", code, response)
|
||||||
|
}
|
||||||
|
password, err := a.savedBackupPassword(context.Background())
|
||||||
|
if err != nil || password != "SharedBackupPassword9" {
|
||||||
|
t.Fatalf("saved password changed: %q, %v", password, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicServerIPValidation(t *testing.T) {
|
||||||
|
for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} {
|
||||||
|
if !isPublicIP(net.ParseIP(value)) {
|
||||||
|
t.Errorf("public IP rejected: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1"} {
|
||||||
|
if isPublicIP(net.ParseIP(value)) {
|
||||||
|
t.Errorf("non-public IP accepted: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := detectPublicServerIP(context.Background(), "203.0.113.10"); got != "203.0.113.10" {
|
||||||
|
t.Fatalf("literal public IP = %q", got)
|
||||||
|
}
|
||||||
|
if got := detectPublicServerIP(context.Background(), "127.0.0.1"); got != "" {
|
||||||
|
t.Fatalf("literal private IP = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteRuntimeBackupEnv(t *testing.T) {
|
||||||
|
t.Setenv("LANQIN_PUBLIC_HOSTNAME", "mail.example.com")
|
||||||
|
t.Setenv("LANQIN_TEST_QUOTED", "value'with\\slashes\nand-newline")
|
||||||
|
t.Setenv("LANQIN_BACKUP_DIR", "/backups")
|
||||||
|
t.Setenv("LANQIN_UPDATE_SERVICE_URL", "http://updater:8080/v1/update")
|
||||||
|
t.Setenv("UNRELATED_SECRET", "must-not-be-backed-up")
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
if err := writeRuntimeBackupEnv(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contents := string(raw)
|
||||||
|
for _, expected := range []string{"LANQIN_PUBLIC_HOSTNAME='mail.example.com'", `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'`} {
|
||||||
|
if !strings.Contains(contents, expected) {
|
||||||
|
t.Errorf("backup environment missing %q: %s", expected, contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, excluded := range []string{"UNRELATED_SECRET", "must-not-be-backed-up", "LANQIN_BACKUP_DIR", "LANQIN_UPDATE_SERVICE_URL", "http://updater:8080"} {
|
||||||
|
if strings.Contains(contents, excluded) {
|
||||||
|
t.Fatalf("backup environment included excluded value %q", excluded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("backup environment permissions = %v, %v", info.Mode().Perm(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupAssetsAvailableWithBundledCompose(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
compose := filepath.Join(dir, "deploy", "docker-compose.yml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(compose), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!",
|
||||||
|
AllowInsecureHTTP: true, BackupSourceDir: filepath.Dir(compose), BackupDir: filepath.Join(dir, "data", "disaster-backups"),
|
||||||
|
})
|
||||||
|
stopTestWorkers(a)
|
||||||
|
if !a.backupAssetsAvailable() {
|
||||||
|
t.Fatal("bundled compose did not enable complete backups")
|
||||||
|
}
|
||||||
|
if err := os.Remove(compose); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.backupAssetsAvailable() {
|
||||||
|
t.Fatal("missing bundled compose incorrectly enabled complete backups")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := newTestAppWithConfig(t, Config{
|
||||||
|
Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
|
||||||
|
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
|
||||||
|
PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
|
||||||
|
})
|
||||||
|
|
||||||
|
ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
|
||||||
|
if err != nil || ciphertext == "BackupPassword123!" {
|
||||||
|
t.Fatalf("password encryption failed: %q %v", ciphertext, err)
|
||||||
|
}
|
||||||
|
plain, err := a.decryptBackupPassword(ciphertext)
|
||||||
|
if err != nil || plain != "BackupPassword123!" {
|
||||||
|
t.Fatalf("password decryption = %q, %v", plain, err)
|
||||||
|
}
|
||||||
|
if !validTelegramPrivateChatID("-1001234567890") {
|
||||||
|
t.Fatal("private Telegram group chat ID was rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
|
||||||
|
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, err := a.backupTelegramReport(context.Background(), path, info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
|
||||||
|
if !strings.Contains(report, expected) {
|
||||||
|
t.Errorf("report missing %q: %s", expected, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(report, "newszxcn.com(管理员)") {
|
||||||
|
t.Fatal("domain list incorrectly contains account role")
|
||||||
|
}
|
||||||
|
if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
|
||||||
|
t.Fatal("report leaked a password")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ type Config struct {
|
|||||||
ReleaseAPIURL string
|
ReleaseAPIURL string
|
||||||
UpdateServiceURL string
|
UpdateServiceURL string
|
||||||
UpdateServiceToken string
|
UpdateServiceToken string
|
||||||
|
BackupSourceDir string
|
||||||
|
BackupDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() Config {
|
func LoadConfig() Config {
|
||||||
@@ -131,6 +133,8 @@ func LoadConfig() Config {
|
|||||||
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
||||||
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
||||||
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
||||||
|
BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", "/usr/share/newszxcn-email/deploy"),
|
||||||
|
BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -64,16 +65,22 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
|
|||||||
maxChars = 8000
|
maxChars = 8000
|
||||||
}
|
}
|
||||||
text, truncated := truncateRunes(text, maxChars)
|
text, truncated := truncateRunes(text, maxChars)
|
||||||
|
translatedHTMLResult := make(chan string, 1)
|
||||||
|
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||||
|
go func() {
|
||||||
|
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
||||||
|
translatedHTMLResult <- translatedHTML
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
translatedHTMLResult <- ""
|
||||||
|
}
|
||||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
||||||
respondError(w, http.StatusBadGateway, "translation failed")
|
respondError(w, http.StatusBadGateway, "translation failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
translatedHTML := ""
|
translatedHTML := <-translatedHTMLResult
|
||||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
|
||||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,53 +138,97 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
|
|||||||
maxChars = 8000
|
maxChars = 8000
|
||||||
}
|
}
|
||||||
text, truncated := truncateRunes(text, maxChars)
|
text, truncated := truncateRunes(text, maxChars)
|
||||||
|
translatedHTMLResult := make(chan string, 1)
|
||||||
|
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
||||||
|
go func() {
|
||||||
|
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
||||||
|
translatedHTMLResult <- translatedHTML
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
translatedHTMLResult <- ""
|
||||||
|
}
|
||||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
||||||
respondError(w, http.StatusBadGateway, "translation failed")
|
respondError(w, http.StatusBadGateway, "translation failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
translatedHTML := ""
|
translatedHTML := <-translatedHTMLResult
|
||||||
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
|
||||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||||
}
|
}
|
||||||
|
|
||||||
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
||||||
|
return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type htmlTextTranslator func(context.Context, string, string) (string, string, error)
|
||||||
|
|
||||||
|
func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) {
|
||||||
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
type translationJob struct {
|
||||||
|
node *html.Node
|
||||||
|
original string
|
||||||
|
text string
|
||||||
|
}
|
||||||
remaining := maxChars
|
remaining := maxChars
|
||||||
var translateNode func(*html.Node) error
|
jobs := make([]translationJob, 0)
|
||||||
translateNode = func(n *html.Node) error {
|
var collect func(*html.Node)
|
||||||
|
collect = func(n *html.Node) {
|
||||||
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
if n.Type == html.TextNode {
|
if n.Type == html.TextNode {
|
||||||
text := strings.TrimSpace(n.Data)
|
text := strings.TrimSpace(n.Data)
|
||||||
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
||||||
limited, _ := truncateRunes(text, remaining)
|
limited, _ := truncateRunes(text, remaining)
|
||||||
remaining -= utf8.RuneCountInString(limited)
|
remaining -= utf8.RuneCountInString(limited)
|
||||||
translated, _, err := googleFreeTranslate(ctx, limited, target)
|
jobs = append(jobs, translationJob{node: n, original: text, text: limited})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n.Data = strings.Replace(n.Data, text, translated, 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
if err := translateNode(c); err != nil {
|
collect(c)
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
if err := translateNode(n); err != nil {
|
collect(n)
|
||||||
return "", err
|
}
|
||||||
}
|
results := make([]string, len(jobs))
|
||||||
|
jobIndexes := make(chan int)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
workers := min(4, len(jobs))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range workers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for index := range jobIndexes {
|
||||||
|
translated, _, translateErr := translator(ctx, jobs[index].text, target)
|
||||||
|
if translateErr != nil {
|
||||||
|
select {
|
||||||
|
case errCh <- translateErr:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[index] = translated
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for index := range jobs {
|
||||||
|
jobIndexes <- index
|
||||||
|
}
|
||||||
|
close(jobIndexes)
|
||||||
|
wg.Wait()
|
||||||
|
select {
|
||||||
|
case translateErr := <-errCh:
|
||||||
|
return "", translateErr
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
for index, job := range jobs {
|
||||||
|
job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1)
|
||||||
}
|
}
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestParseGoogleTranslateResponse(t *testing.T) {
|
func TestParseGoogleTranslateResponse(t *testing.T) {
|
||||||
raw := []any{
|
raw := []any{
|
||||||
@@ -20,6 +24,22 @@ func TestParseGoogleTranslateResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) {
|
||||||
|
translator := func(_ context.Context, text, target string) (string, string, error) {
|
||||||
|
return strings.ToUpper(text) + "-" + target, "en", nil
|
||||||
|
}
|
||||||
|
got, err := translateHTMLTextNodesWith(context.Background(), nil, `<p>Hello <strong>world</strong></p><pre>keep me</pre>`, "zh-CN", 100, translator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `<p>HELLO-zh-CN <strong>WORLD-zh-CN</strong></p>`) {
|
||||||
|
t.Fatalf("translated HTML = %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `<pre>keep me</pre>`) {
|
||||||
|
t.Fatalf("code block was translated: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTruncateRunes(t *testing.T) {
|
func TestTruncateRunes(t *testing.T) {
|
||||||
got, truncated := truncateRunes("你好world", 4)
|
got, truncated := truncateRunes("你好world", 4)
|
||||||
if got != "你好wo" || !truncated {
|
if got != "你好wo" || !truncated {
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ func (a *App) Router() http.Handler {
|
|||||||
r.Use(a.requireAdminAccess)
|
r.Use(a.requireAdminAccess)
|
||||||
r.Get("/admin/system/version", a.handleSystemVersion)
|
r.Get("/admin/system/version", a.handleSystemVersion)
|
||||||
r.Post("/admin/system/update", a.handleSystemUpdate)
|
r.Post("/admin/system/update", a.handleSystemUpdate)
|
||||||
|
r.Get("/admin/backups", a.handleListBackups)
|
||||||
|
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
|
||||||
|
r.Post("/admin/backups/password", a.handleUpdateBackupPassword)
|
||||||
|
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
|
||||||
|
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
|
||||||
|
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
|
||||||
|
r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
|
||||||
|
r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
|
||||||
|
r.Post("/admin/backups", a.handleCreateBackup)
|
||||||
|
r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
|
||||||
|
r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
|
||||||
|
r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
|
||||||
|
r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
|
||||||
|
r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
|
||||||
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
||||||
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
||||||
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type telegramUpdate struct {
|
|||||||
Chat struct {
|
Chat struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -112,7 +113,7 @@ func normalizeTelegramBodyMode(value string) string {
|
|||||||
|
|
||||||
func validTelegramPrivateChatID(value string) bool {
|
func validTelegramPrivateChatID(value string) bool {
|
||||||
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
return err == nil && id > 0
|
return err == nil && id != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -259,6 +260,50 @@ func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCod
|
|||||||
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
|
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type telegramDiscoveredChat struct {
|
||||||
|
ChatID string `json:"chatId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) discoverTelegramGroups(ctx context.Context, token, pairingCode string) ([]telegramDiscoveredChat, error) {
|
||||||
|
var updates []telegramUpdate
|
||||||
|
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
|
||||||
|
"limit": 100, "timeout": 0, "allowed_updates": []string{"message"},
|
||||||
|
}, &updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
found := make([]telegramDiscoveredChat, 0)
|
||||||
|
seen := make(map[int64]bool)
|
||||||
|
for i := len(updates) - 1; i >= 0; i-- {
|
||||||
|
message := updates[i].Message
|
||||||
|
if message == nil || (message.Chat.Type != "group" && message.Chat.Type != "supergroup") || message.Chat.ID >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(message.Text)
|
||||||
|
fields := strings.Fields(text)
|
||||||
|
matches := strings.EqualFold(text, pairingCode)
|
||||||
|
if len(fields) == 2 && strings.HasPrefix(strings.ToLower(fields[0]), "/newszxcn") {
|
||||||
|
matches = strings.EqualFold(fields[1], pairingCode)
|
||||||
|
}
|
||||||
|
if !matches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[message.Chat.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[message.Chat.ID] = true
|
||||||
|
name := strings.TrimSpace(message.Chat.Title)
|
||||||
|
if name == "" {
|
||||||
|
name = "Telegram 群组"
|
||||||
|
}
|
||||||
|
found = append(found, telegramDiscoveredChat{ChatID: strconv.FormatInt(message.Chat.ID, 10), DisplayName: name})
|
||||||
|
}
|
||||||
|
if len(found) == 0 {
|
||||||
|
return nil, errors.New("未找到匹配的群组,请确认机器人已加入群组,并在群里发送查询命令")
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newTelegramPairingCode() (string, error) {
|
func newTelegramPairingCode() (string, error) {
|
||||||
raw := make([]byte, 6)
|
raw := make([]byte, 6)
|
||||||
if _, err := rand.Read(raw); err != nil {
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||||
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { useLogout } from "@/hooks/use-logout"
|
import { useLogout } from "@/hooks/use-logout"
|
||||||
import { AuthGuard } from "@/components/auth-guard"
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
@@ -35,6 +35,7 @@ const adminSections: { key: string; label: string; icon: React.ReactNode; permis
|
|||||||
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
||||||
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||||
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
||||||
|
{ key: "backups", label: "备份与恢复", icon: <ArchiveRestore />, permissions: ["admin.settings.view"] },
|
||||||
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ function ProtectedContent() {
|
|||||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
||||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
|
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
|
||||||
|
|
||||||
if (isMailRoute || isProfileRoute) {
|
if (isMailRoute || isProfileRoute) {
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
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:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
+20
-2
@@ -12,10 +12,12 @@
|
|||||||
--popover-foreground: 0 0% 9%;
|
--popover-foreground: 0 0% 9%;
|
||||||
--primary: 0 0% 12%;
|
--primary: 0 0% 12%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--action-primary: 0 0% 12%;
|
||||||
|
--action-primary-foreground: 0 0% 98%;
|
||||||
--secondary: 0 0% 96.1%;
|
--secondary: 0 0% 96.1%;
|
||||||
--secondary-foreground: 0 0% 12%;
|
--secondary-foreground: 0 0% 12%;
|
||||||
--muted: 0 0% 96.1%;
|
--muted: 0 0% 96.1%;
|
||||||
--muted-foreground: 0 0% 42%;
|
--muted-foreground: 0 0% 36%;
|
||||||
--accent: 0 0% 95%;
|
--accent: 0 0% 95%;
|
||||||
--accent-foreground: 0 0% 12%;
|
--accent-foreground: 0 0% 12%;
|
||||||
--destructive: 358 88% 61%;
|
--destructive: 358 88% 61%;
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
--sidebar-accent-foreground: 0 0% 12%;
|
--sidebar-accent-foreground: 0 0% 12%;
|
||||||
--sidebar-border: 0 0% 89.8%;
|
--sidebar-border: 0 0% 89.8%;
|
||||||
--sidebar-ring: 0 0% 42%;
|
--sidebar-ring: 0 0% 42%;
|
||||||
|
--compose-send: 217 89% 43%;
|
||||||
|
--compose-send-hover: 216 94% 32%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
@@ -61,10 +65,12 @@
|
|||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--action-primary: 240 4% 24%;
|
||||||
|
--action-primary-foreground: 0 0% 98%;
|
||||||
--secondary: 240 3.7% 15.9%;
|
--secondary: 240 3.7% 15.9%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 240 3.7% 15.9%;
|
--muted: 240 3.7% 15.9%;
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--muted-foreground: 240 5% 72%;
|
||||||
--accent: 240 3.7% 15.9%;
|
--accent: 240 3.7% 15.9%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
@@ -80,10 +86,22 @@
|
|||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
--compose-send: 217 89% 52%;
|
||||||
|
--compose-send-hover: 214 94% 60%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@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 {
|
.mail-shell-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
|
grid-template-columns: var(--app-sidebar-width, 18rem) minmax(0, 1fr);
|
||||||
|
|||||||
@@ -202,6 +202,12 @@ export type SystemUpdateResult = {
|
|||||||
targetVersion: string
|
targetVersion: string
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
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; transfers: BackupTransfer[] }
|
||||||
export type SystemSettings = {
|
export type SystemSettings = {
|
||||||
publicHostname: string
|
publicHostname: string
|
||||||
publicBaseUrl: string
|
publicBaseUrl: string
|
||||||
|
|||||||
+13
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
|
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, BackupList, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
|
||||||
export * from "./api-types"
|
export * from "./api-types"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
@@ -211,6 +211,18 @@ export const api = {
|
|||||||
},
|
},
|
||||||
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
|
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
|
||||||
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
|
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
|
||||||
|
backups: () => request<BackupList>("/api/admin/backups"),
|
||||||
|
createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }),
|
||||||
|
updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request<import("./api-types").BackupSchedule>("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
updateBackupPassword: (password: string, confirmPassword: string) => request<{ passwordSet: boolean; passwordHint: string }>("/api/admin/backups/password", { method: "POST", body: JSON.stringify({ password, confirmPassword }) }),
|
||||||
|
testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||||
|
discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }),
|
||||||
|
connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }),
|
||||||
|
disconnectGoogleDrive: () => request<{ ok: boolean }>("/api/admin/backups/google-drive", { method: "DELETE" }),
|
||||||
|
verifyBackup: (name: string) => request<{ ok: boolean; sha256: string }>(`/api/admin/backups/${encodeURIComponent(name)}/verify`, { method: "POST", timeoutMs: 60_000 }),
|
||||||
|
sendBackupTelegram: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/telegram`, { method: "POST", timeoutMs: 10 * 60_000 }),
|
||||||
|
sendBackupGoogleDrive: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/google-drive`, { method: "POST", timeoutMs: 30 * 60_000 }),
|
||||||
|
deleteBackup: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
||||||
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|||||||
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
||||||
import { Toaster } from "@/components/ui/toaster"
|
import { Toaster } from "@/components/ui/toaster"
|
||||||
import { LanguageDomSync } from "@/lib/language"
|
import { LanguageDomSync } from "@/lib/language"
|
||||||
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
import { ProtectedLayout } from "@/components/protected-layout"
|
import { ProtectedLayout } from "@/components/protected-layout"
|
||||||
import { AdminOnly } from "@/components/admin-only"
|
import { AdminOnly } from "@/components/admin-only"
|
||||||
import "./index.css"
|
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 ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
|
||||||
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
|
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 queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{ path: "/login", element: <LoginPage /> },
|
{ path: "/login", element: <LoginPage /> },
|
||||||
|
|||||||
+373
-10
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import DOMPurify from "dompurify"
|
import DOMPurify from "dompurify"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, Github, Globe2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
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 { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -24,9 +24,9 @@ import { SystemVersionDialog } from "@/components/system-version-dialog"
|
|||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
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" | "settings"
|
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" | "about"
|
||||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ const sectionMeta: Record<Section, { label: string; frontLabel: string; descript
|
|||||||
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
|
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
|
||||||
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
|
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
|
||||||
sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
|
sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
|
||||||
|
backups: { label: "备份与恢复", frontLabel: "数据保护", description: "创建、校验和下载可迁移的加密完整备份。" },
|
||||||
settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
|
settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
|
||||||
}
|
}
|
||||||
const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string>
|
const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string>
|
||||||
@@ -52,6 +53,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
|||||||
aliases: ["admin.aliases.view"],
|
aliases: ["admin.aliases.view"],
|
||||||
messages: ["admin.messages.view"],
|
messages: ["admin.messages.view"],
|
||||||
sendAudit: ["admin.messages.view"],
|
sendAudit: ["admin.messages.view"],
|
||||||
|
backups: ["admin.settings.view"],
|
||||||
settings: ["admin.settings.view", "admin.templates.view"],
|
settings: ["admin.settings.view", "admin.templates.view"],
|
||||||
}
|
}
|
||||||
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
|
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
|
||||||
@@ -102,7 +104,7 @@ export function AdminPage() {
|
|||||||
const aliasItems = aliases.data?.items || []
|
const aliasItems = aliases.data?.items || []
|
||||||
const userItems = users.data?.items || []
|
const userItems = users.data?.items || []
|
||||||
const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== superAdminPermissionGroupId && group.id !== regularUserPermissionGroupId)
|
const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== superAdminPermissionGroupId && group.id !== regularUserPermissionGroupId)
|
||||||
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]) && (key !== "backups" || user?.role === "admin"))
|
||||||
const rawSection = params.get("section") as Section | null
|
const rawSection = params.get("section") as Section | null
|
||||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||||
const sectionQuery = section === "overview" ? overview
|
const sectionQuery = section === "overview" ? overview
|
||||||
@@ -155,6 +157,7 @@ export function AdminPage() {
|
|||||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||||
|
{section === "backups" && <BackupsSection />}
|
||||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
|
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
|
||||||
</main>
|
</main>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -255,6 +258,355 @@ 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="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateBackupPassword(length = 24) {
|
||||||
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%"
|
||||||
|
const values = new Uint32Array(length)
|
||||||
|
crypto.getRandomValues(values)
|
||||||
|
return Array.from(values, (value) => alphabet[value % alphabet.length]).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackupsSection() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const backups = useQuery({
|
||||||
|
queryKey: ["admin", "backups"],
|
||||||
|
queryFn: api.backups,
|
||||||
|
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("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = React.useState("")
|
||||||
|
const [showCreatePassword, setShowCreatePassword] = React.useState(false)
|
||||||
|
const [sendAfterCreate, setSendAfterCreate] = React.useState(true)
|
||||||
|
const [driveAfterCreate, setDriveAfterCreate] = React.useState(false)
|
||||||
|
const [deleteName, setDeleteName] = React.useState("")
|
||||||
|
|
||||||
|
const [scheduleEnabled, setScheduleEnabled] = React.useState(false)
|
||||||
|
const [scheduleDays, setScheduleDays] = React.useState("7")
|
||||||
|
const [customDays, setCustomDays] = React.useState("14")
|
||||||
|
const [schedulePassword, setSchedulePassword] = React.useState("")
|
||||||
|
const [scheduleConfirmPassword, setScheduleConfirmPassword] = React.useState("")
|
||||||
|
const [showSchedulePassword, setShowSchedulePassword] = React.useState(false)
|
||||||
|
const [backupChatId, setBackupChatId] = React.useState("")
|
||||||
|
const [telegramMode, setTelegramMode] = React.useState<"system" | "custom">("system")
|
||||||
|
const [telegramEnabled, setTelegramEnabled] = React.useState(true)
|
||||||
|
const [googleDriveEnabled, setGoogleDriveEnabled] = React.useState(false)
|
||||||
|
const [googleClientId, setGoogleClientId] = React.useState("")
|
||||||
|
const [googleClientSecret, setGoogleClientSecret] = React.useState("")
|
||||||
|
const [googleFolderName, setGoogleFolderName] = React.useState("NewSzxcn Backups")
|
||||||
|
const [telegramConfigOpen, setTelegramConfigOpen] = React.useState(false)
|
||||||
|
const [backupGroupPairing, setBackupGroupPairing] = React.useState<TelegramPairing | null>(null)
|
||||||
|
const [discoveredBackupGroups, setDiscoveredBackupGroups] = React.useState<{ chatId: string; displayName: string }[]>([])
|
||||||
|
const [googleConfigOpen, setGoogleConfigOpen] = React.useState(false)
|
||||||
|
const [passwordConfigOpen, setPasswordConfigOpen] = React.useState(false)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!backups.data) return
|
||||||
|
const days = backups.data.schedule.days || 7
|
||||||
|
setScheduleEnabled(backups.data.schedule.enabled)
|
||||||
|
setScheduleDays([3, 5, 7, 30].includes(days) ? String(days) : "custom")
|
||||||
|
setCustomDays(String(days))
|
||||||
|
setBackupChatId(backups.data.schedule.chatId || "")
|
||||||
|
setTelegramMode(backups.data.schedule.telegramMode === "custom" ? "custom" : "system")
|
||||||
|
setTelegramEnabled(backups.data.schedule.telegramEnabled)
|
||||||
|
setGoogleDriveEnabled(backups.data.schedule.googleDriveEnabled)
|
||||||
|
setGoogleClientId(backups.data.googleDrive.clientId || "")
|
||||||
|
setGoogleFolderName(backups.data.googleDrive.folderName || "NewSzxcn Backups")
|
||||||
|
setDriveAfterCreate(backups.data.googleDrive.connected)
|
||||||
|
setSendAfterCreate(backups.data.telegramSet)
|
||||||
|
}, [backups.data])
|
||||||
|
React.useEffect(() => {
|
||||||
|
const drive = new URLSearchParams(window.location.search).get("drive")
|
||||||
|
if (!drive) return
|
||||||
|
toast({ title: drive === "connected" ? "Google 云端硬盘已连接" : "Google 授权未完成" })
|
||||||
|
window.history.replaceState({}, "", "/admin?section=backups")
|
||||||
|
}, [toast])
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () => api.createBackup(password, confirmPassword, sendAfterCreate, driveAfterCreate),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setCreateOpen(false); setPassword(""); setConfirmPassword("")
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin", "backups"] })
|
||||||
|
toast({ title: "备份任务已开始", description: "可以留在此页面查看进度。" })
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "无法创建备份", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
})
|
||||||
|
const saveSchedule = useMutation({
|
||||||
|
mutationFn: () => api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: "", confirmPassword: "", serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled, googleClientId, googleClientSecret, googleFolderName }),
|
||||||
|
onSuccess: async () => { setGoogleClientSecret(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份设置已保存" }) },
|
||||||
|
onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
})
|
||||||
|
const savePassword = useMutation({
|
||||||
|
mutationFn: () => api.updateBackupPassword(schedulePassword, scheduleConfirmPassword),
|
||||||
|
onSuccess: async () => { setPasswordConfigOpen(false); setSchedulePassword(""); setScheduleConfirmPassword(""); setShowSchedulePassword(false); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "统一备份密码已保存", description: "以后创建的手动和定时备份都会使用新密码。" }) },
|
||||||
|
onError: (error) => toast({ title: "密码保存失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
})
|
||||||
|
const verify = useMutation({
|
||||||
|
mutationFn: api.verifyBackup,
|
||||||
|
onSuccess: (result) => toast({ title: result.ok ? "备份校验通过" : "备份校验失败", description: result.ok ? `SHA-256:${result.sha256.slice(0, 16)}...` : "文件可能已损坏,请勿用于恢复。" }),
|
||||||
|
onError: (error) => toast({ title: "校验失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
})
|
||||||
|
const sendTelegram = useMutation({
|
||||||
|
mutationFn: api.sendBackupTelegram,
|
||||||
|
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已开始发送到 Telegram" }) },
|
||||||
|
onError: (error) => toast({ title: "发送失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
})
|
||||||
|
const testBackupTelegram = useMutation({
|
||||||
|
mutationFn: () => api.testBackupTelegram({ mode: telegramMode, chatId: backupChatId }),
|
||||||
|
onSuccess: () => toast({ title: "Telegram 测试通知已发送" }),
|
||||||
|
onError: (error) => toast({ title: "测试失败", description: error instanceof Error ? error.message : "请检查机器人和 Chat ID" }),
|
||||||
|
})
|
||||||
|
const createBackupGroupPairing = useMutation({
|
||||||
|
mutationFn: () => api.createTelegramPairing(""),
|
||||||
|
onSuccess: (pairing) => { setBackupGroupPairing(pairing); setDiscoveredBackupGroups([]); toast({ title: "群组查询码已生成" }) },
|
||||||
|
onError: (error) => toast({ title: "无法生成查询码", description: error instanceof Error ? error.message : "请先绑定 Telegram 机器人" }),
|
||||||
|
})
|
||||||
|
const discoverBackupGroup = useMutation({
|
||||||
|
mutationFn: () => api.discoverBackupTelegramGroup(backupGroupPairing?.code || ""),
|
||||||
|
onSuccess: ({ items }) => {
|
||||||
|
setDiscoveredBackupGroups(items)
|
||||||
|
if (items.length === 1) setBackupChatId(items[0].chatId)
|
||||||
|
toast({ title: `找到 ${items.length} 个群组`, description: items.length === 1 ? "已自动选中" : "请选择备份群组" })
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "未找到群组", description: error instanceof Error ? error.message : "请在群组发送查询命令后重试" }),
|
||||||
|
})
|
||||||
|
const sendDrive = useMutation({
|
||||||
|
mutationFn: api.sendBackupGoogleDrive,
|
||||||
|
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已开始上传到 Google 云端硬盘" }) },
|
||||||
|
onError: (error) => toast({ title: "上传失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
})
|
||||||
|
const connectDrive = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: "", confirmPassword: "", serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled: false, googleClientId, googleClientSecret, googleFolderName })
|
||||||
|
return api.connectGoogleDrive()
|
||||||
|
},
|
||||||
|
onSuccess: ({ url }) => { window.location.href = url },
|
||||||
|
onError: (error) => toast({ title: "无法连接 Google 云端硬盘", description: error instanceof Error ? error.message : "请检查 OAuth 配置" }),
|
||||||
|
})
|
||||||
|
const disconnectDrive = useMutation({
|
||||||
|
mutationFn: api.disconnectGoogleDrive,
|
||||||
|
onSuccess: async () => { setGoogleDriveEnabled(false); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已断开 Google 云端硬盘" }) },
|
||||||
|
})
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: api.deleteBackup,
|
||||||
|
onSuccess: async () => { setDeleteName(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份已删除" }) },
|
||||||
|
onError: (error) => toast({ title: "删除失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
})
|
||||||
|
const job = backups.data?.job
|
||||||
|
const canCreate = backups.data?.enabled && job?.status !== "running"
|
||||||
|
function submitCreate() {
|
||||||
|
if (!backups.data?.schedule.passwordSet && password.length < 8) { toast({ title: "密码至少需要 8 个字符" }); return }
|
||||||
|
if (!backups.data?.schedule.passwordSet && password !== confirmPassword) { toast({ title: "两次输入的密码不一致" }); return }
|
||||||
|
create.mutate()
|
||||||
|
}
|
||||||
|
function generateCreatePassword() {
|
||||||
|
const generated = generateBackupPassword()
|
||||||
|
setPassword(generated)
|
||||||
|
setConfirmPassword(generated)
|
||||||
|
setShowCreatePassword(true)
|
||||||
|
toast({ title: "已生成 24 位备份密码", description: "请将密码保存到密码管理器,恢复时必须使用。" })
|
||||||
|
}
|
||||||
|
function generateSchedulePassword() {
|
||||||
|
const generated = generateBackupPassword()
|
||||||
|
setSchedulePassword(generated)
|
||||||
|
setScheduleConfirmPassword(generated)
|
||||||
|
setShowSchedulePassword(true)
|
||||||
|
toast({ title: "已生成 24 位备份密码", description: "保存设置前,请先将密码存入密码管理器。" })
|
||||||
|
}
|
||||||
|
async function copyBackupPassword(value: string) {
|
||||||
|
if (!value) return
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
toast({ title: "备份密码已复制" })
|
||||||
|
}
|
||||||
|
function downloadBackupPassword(value: string) {
|
||||||
|
if (!value) return
|
||||||
|
const createdAt = new Date().toLocaleString("zh-CN", { hour12: false })
|
||||||
|
const content = `NewSzxcn Email 备份恢复密码\n\n密码:${value}\n生成时间:${createdAt}\n\n请妥善保管。恢复备份时必须输入此密码,系统无法找回。\n`
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" }))
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = url
|
||||||
|
link.download = `newszxcn-backup-password-${new Date().toISOString().slice(0, 10)}.txt`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast({ title: "密码文本已下载", description: "请导入密码管理器,不要与备份文件存放在一起。" })
|
||||||
|
}
|
||||||
|
function PasswordTools({ value, visible, onVisibleChange, onGenerate }: { value: string; visible: boolean; onVisibleChange: (visible: boolean) => void; onGenerate: () => void }) {
|
||||||
|
return <div className="flex items-center gap-0.5">
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="生成 24 位密码" aria-label="生成 24 位密码" onClick={onGenerate}><KeyRound className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" title={visible ? "隐藏密码" : "查看密码"} aria-label={visible ? "隐藏密码" : "查看密码"} disabled={!value} onClick={() => onVisibleChange(!visible)}>{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="复制密码" aria-label="复制密码" disabled={!value} onClick={() => copyBackupPassword(value)}><Copy className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="保存密码文件" aria-label="保存密码文件" disabled={!value} onClick={() => downloadBackupPassword(value)}><Download className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
function submitSchedule() {
|
||||||
|
if (scheduleEnabled && !backups.data?.schedule.passwordSet) { setPasswordConfigOpen(true); toast({ title: "请先设置统一备份密码" }); return }
|
||||||
|
saveSchedule.mutate()
|
||||||
|
}
|
||||||
|
function submitPassword() {
|
||||||
|
if (schedulePassword.length < 8) { toast({ title: "备份密码至少需要 8 个字符" }); return }
|
||||||
|
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)]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0 pb-3">
|
||||||
|
<div><CardTitle>完整加密备份</CardTitle><p className="mt-1 text-sm text-muted-foreground">包含账号、邮件、附件、DKIM、证书和部署配置。</p></div>
|
||||||
|
<Button type="button" disabled={!canCreate} onClick={() => setCreateOpen(true)}>
|
||||||
|
{job?.status === "running" ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <HardDrive className="mr-2 h-4 w-4" />}
|
||||||
|
{job?.status === "running" ? "生成中" : "创建备份"}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{!backups.data?.enabled && <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">当前版本缺少完整备份组件。请更新到最新修复版本,更新完成后刷新本页即可创建备份。</div>}
|
||||||
|
{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">
|
||||||
|
{(backups.data?.items || []).slice(0, 4).map((item) => (
|
||||||
|
<div key={item.name} className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1"><div className="truncate text-sm font-medium" title={item.name}>{item.name}</div><div className="text-xs text-muted-foreground">{formatDate(item.createdAt)} · {formatBytes(item.size)}</div></div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" title="校验备份" disabled={verify.isPending} onClick={() => verify.mutate(item.name)}><ShieldCheck className="h-4 w-4" /></Button>
|
||||||
|
<DropdownMenu><DropdownMenuTrigger asChild><Button type="button" variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled={!backups.data?.telegramSet || item.size > (backups.data?.telegramLimit || 0)} onClick={() => sendTelegram.mutate(item.name)}><Send className="mr-2 h-4 w-4" />发送到 Telegram</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={!backups.data?.googleDrive.connected} onClick={() => sendDrive.mutate(item.name)}><Cloud className="mr-2 h-4 w-4" />上传到 Google 云端硬盘</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild><a href={`/api/admin/backups/${encodeURIComponent(item.name)}/download`}><Download className="mr-2 h-4 w-4" />下载</a></DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onClick={() => setDeleteName(item.name)}><Trash2 className="mr-2 h-4 w-4" />删除</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent></DropdownMenu>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!backups.isLoading && (backups.data?.items.length || 0) === 0 && <div className="px-3 py-4 text-center text-sm text-muted-foreground">暂无完整备份</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3"><CardTitle>新服务器恢复</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p><strong className="text-foreground">1.</strong> 将原始加密备份上传到 <strong className="text-foreground">/root/</strong>,不要解压。</p>
|
||||||
|
<p><strong className="text-foreground">2.</strong> 运行官方安装脚本,菜单输入 <strong className="text-foreground">2</strong>。</p>
|
||||||
|
<p><strong className="text-foreground">3.</strong> 选择“本地上传”;多份备份会显示 1、2、3。</p>
|
||||||
|
<p><strong className="text-foreground">4.</strong> 输入序号和备份密码开始恢复。</p>
|
||||||
|
<div className="mt-3 rounded-md bg-muted/60 px-3 py-2 text-xs">恢复完成后可输入 <strong className="text-foreground">ns</strong> 打开管理菜单。运行中的服务器不会在网页内覆盖数据。</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3"><div><CardTitle>定时备份</CardTitle><p className="mt-1 text-sm text-muted-foreground">按周期创建加密备份并保存到选定位置。</p></div><Switch checked={scheduleEnabled} onCheckedChange={setScheduleEnabled} /></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div className="space-y-2"><Label>备份周期</Label><div className={cn("grid gap-2", scheduleDays === "custom" && "grid-cols-[minmax(0,1fr)_5.5rem]")}><Select value={scheduleDays} onValueChange={setScheduleDays}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="3">每 3 天</SelectItem><SelectItem value="5">每 5 天</SelectItem><SelectItem value="7">每 7 天</SelectItem><SelectItem value="30">每 30 天</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select>{scheduleDays === "custom" && <Input id="backup-custom-days" aria-label="自定义天数" title="自定义天数" type="number" min={1} max={365} value={customDays} onChange={(event) => setCustomDays(event.target.value)} />}</div></div>
|
||||||
|
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-server-ip">服务器 IP</Label><Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="重新检测" aria-label="重新检测服务器 IP" disabled={backups.isFetching} onClick={() => backups.refetch()}><RefreshCcw className={cn("h-4 w-4", backups.isFetching && "animate-spin")} /></Button></div><Input id="backup-server-ip" readOnly value={backups.data?.schedule.serverIp || ""} placeholder={backups.isLoading ? "正在自动检测" : "未检测到,请检查邮局主机名 DNS"} /><p className="text-xs text-muted-foreground">根据当前邮局主机名的公网 DNS 自动识别。</p></div>
|
||||||
|
<div className="space-y-2"><div className="flex h-7 items-center"><Label>恢复密码</Label></div><div className="flex h-10 items-center gap-3 rounded-md border bg-background px-3"><KeyRound className="h-4 w-4 shrink-0 text-muted-foreground" /><span className="min-w-0 flex-1 truncate font-mono text-sm">{backups.data?.schedule.passwordHint || "尚未设置"}</span><Button type="button" variant="ghost" size="sm" className="shrink-0" onClick={() => setPasswordConfigOpen(true)}>{backups.data?.schedule.passwordSet ? "更换" : "设置"}</Button></div><p className="text-xs text-muted-foreground">手动与定时备份共用。</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y rounded-md border">
|
||||||
|
<div className="flex items-center gap-3 p-3">
|
||||||
|
<Send className="h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1"><div className="flex items-center gap-2 sm:grid sm:grid-cols-[8.5rem_auto]"><span className="text-sm font-medium">Telegram</span><Badge className="w-fit" variant={backups.data?.telegramSet ? "default" : "secondary"}>{backups.data?.telegramSet ? "已配置" : "未配置"}</Badge></div><p className="truncate text-xs text-muted-foreground">{telegramMode === "custom" ? "使用系统机器人推送到备份群组" : "沿用邮件通知接收方"}</p></div>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => setTelegramConfigOpen(true)}>配置</Button>
|
||||||
|
<Switch checked={telegramEnabled} onCheckedChange={setTelegramEnabled} disabled={!backups.data?.telegramSet} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-3">
|
||||||
|
<Cloud className="h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1"><div className="flex items-center gap-2 sm:grid sm:grid-cols-[8.5rem_auto]"><span className="text-sm font-medium">Google 云端硬盘</span><Badge className="w-fit" variant={backups.data?.googleDrive.connected ? "default" : "secondary"}>{backups.data?.googleDrive.connected ? "已连接" : "未连接"}</Badge></div><p className="truncate text-xs text-muted-foreground">{backups.data?.googleDrive.connected ? `保存到 ${googleFolderName}` : "长期保存加密备份"}</p></div>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => setGoogleConfigOpen(true)}>配置</Button>
|
||||||
|
<Switch checked={googleDriveEnabled} onCheckedChange={setGoogleDriveEnabled} disabled={!backups.data?.googleDrive.connected} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end border-t pt-3"><Button type="button" className="shrink-0" disabled={saveSchedule.isPending} onClick={submitSchedule}>{saveSchedule.isPending ? "保存中" : "保存设置"}</Button></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Dialog open={telegramConfigOpen} onOpenChange={setTelegramConfigOpen}>
|
||||||
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-md rounded-lg">
|
||||||
|
<DialogHeader><DialogTitle>Telegram 备份</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2"><Label>备份接收位置</Label><Select value={telegramMode} onValueChange={(value) => setTelegramMode(value === "custom" ? "custom" : "system")}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="system">沿用邮件通知接收方</SelectItem><SelectItem value="custom">自定义备份群组</SelectItem></SelectContent></Select></div>
|
||||||
|
{telegramMode === "system" ? <div className="rounded-md bg-muted/60 px-3 py-2 text-sm text-muted-foreground">使用系统已绑定机器人,备份发送到邮件通知的接收方。</div> : <div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="backup-chat-id">备份群组</Label>
|
||||||
|
<div className="flex gap-2"><Input id="backup-chat-id" inputMode="numeric" value={backupChatId} onChange={(event) => setBackupChatId(event.target.value)} placeholder="群组 Chat ID" /><Button type="button" variant="outline" className="shrink-0" disabled={createBackupGroupPairing.isPending} onClick={() => createBackupGroupPairing.mutate()}>{createBackupGroupPairing.isPending ? "生成中" : "查询群组"}</Button></div>
|
||||||
|
<p className="text-xs text-muted-foreground">请先将系统机器人加入该群组。邮件继续推送到原接收方,备份通知和附件只推送到此群组。</p>
|
||||||
|
{backupGroupPairing && <div className="space-y-3 rounded-md border px-3 py-3">
|
||||||
|
<p className="text-sm">在每个候选群组发送下面的命令,然后点击“完成查询”:</p>
|
||||||
|
<div className="flex items-center gap-2"><code className="min-w-0 flex-1 truncate rounded bg-muted px-2 py-1.5 font-mono text-sm">/newszxcn {backupGroupPairing.code}</code><Button type="button" variant="ghost" size="icon" aria-label="复制群组查询命令" title="复制群组查询命令" onClick={() => navigator.clipboard.writeText(`/newszxcn ${backupGroupPairing.code}`)}><Copy className="h-4 w-4" /></Button></div>
|
||||||
|
<Button type="button" size="sm" disabled={discoverBackupGroup.isPending} onClick={() => discoverBackupGroup.mutate()}>{discoverBackupGroup.isPending ? "查询中" : "完成查询"}</Button>
|
||||||
|
{discoveredBackupGroups.length > 0 && <div className="divide-y rounded-md border">{discoveredBackupGroups.map((group) => <Button key={group.chatId} type="button" variant="ghost" className={cn("h-auto w-full justify-start rounded-none px-3 py-2 text-left", backupChatId === group.chatId && "bg-muted")} onClick={() => { setBackupChatId(group.chatId); setBackupGroupPairing(null); setDiscoveredBackupGroups([]) }}><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{group.displayName}</span><span className="block font-mono text-xs font-normal text-muted-foreground">{group.chatId}</span></span>{backupChatId === group.chatId && <CheckCircle2 className="h-4 w-4 text-primary" />}</Button>)}</div>}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
<Button asChild type="button" variant="outline"><a href="/admin?section=settings&settingsTab=notifications">管理机器人</a></Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" disabled={testBackupTelegram.isPending || (telegramMode === "custom" && !backupChatId)} onClick={() => testBackupTelegram.mutate()}>{testBackupTelegram.isPending ? "发送中" : "测试发送"}</Button>
|
||||||
|
<Button type="button" onClick={() => setTelegramConfigOpen(false)}>完成</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
<p className="text-xs text-muted-foreground">关闭后请点击页面下方“保存设置”使 Chat ID 生效。</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={googleConfigOpen} onOpenChange={setGoogleConfigOpen}>
|
||||||
|
<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>
|
||||||
|
<div className="space-y-2"><Label htmlFor="google-callback-url">Google Cloud 回调地址</Label><div className="flex gap-2"><Input id="google-callback-url" readOnly className="min-w-0 font-mono text-xs" value={`${window.location.origin}/api/admin/backups/google-drive/callback`} /><Button type="button" variant="outline" size="icon" className="shrink-0" title="复制回调地址" aria-label="复制 Google Cloud 回调地址" onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/api/admin/backups/google-drive/callback`); toast({ title: "回调地址已复制" }) }}><Copy className="h-4 w-4" /></Button></div></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
{backups.data?.googleDrive.connected ? <Button type="button" variant="outline" className="text-destructive" onClick={() => { disconnectDrive.mutate(); setGoogleConfigOpen(false) }}>断开连接</Button> : <span />}
|
||||||
|
<div className="flex gap-2"><Button type="button" variant="outline" onClick={() => setGoogleConfigOpen(false)}>取消</Button><Button type="button" disabled={!googleClientId || (!googleClientSecret && !backups.data?.googleDrive.clientSecretSet) || connectDrive.isPending} onClick={() => connectDrive.mutate()}>{backups.data?.googleDrive.connected ? "重新连接" : "连接 Google"}</Button></div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={passwordConfigOpen} onOpenChange={(open) => { if (!savePassword.isPending) { setPasswordConfigOpen(open); if (!open) { setSchedulePassword(""); setScheduleConfirmPassword(""); setShowSchedulePassword(false) } } }}>
|
||||||
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-md rounded-lg">
|
||||||
|
<DialogHeader><DialogTitle>{backups.data?.schedule.passwordSet ? "更换统一备份密码" : "设置统一备份密码"}</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{backups.data?.schedule.passwordSet && <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">当前密码:<span className="font-mono">{backups.data.schedule.passwordHint}</span>。更换只影响以后创建的备份,已有备份仍需原密码恢复。</div>}
|
||||||
|
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-schedule-password">新备份密码</Label><PasswordTools value={schedulePassword} visible={showSchedulePassword} onVisibleChange={setShowSchedulePassword} onGenerate={generateSchedulePassword} /></div><Input id="backup-schedule-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={schedulePassword} onChange={(event) => setSchedulePassword(event.target.value)} placeholder="至少 8 个字符" /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="backup-schedule-confirm-password">确认新备份密码</Label><Input id="backup-schedule-confirm-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={scheduleConfirmPassword} onChange={(event) => setScheduleConfirmPassword(event.target.value)} placeholder="再次输入新备份密码" /></div>
|
||||||
|
<p className="text-xs text-muted-foreground">生成密码后可查看、复制或下载密码文本。请存入密码管理器,并与备份文件分开保存。</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button type="button" variant="outline" onClick={() => setPasswordConfigOpen(false)} disabled={savePassword.isPending}>取消</Button><Button type="button" onClick={submitPassword} disabled={savePassword.isPending}>{savePassword.isPending ? "保存中" : "保存密码"}</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={createOpen} onOpenChange={(open) => { if (!create.isPending) setCreateOpen(open) }}>
|
||||||
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-md rounded-lg">
|
||||||
|
<DialogHeader><DialogTitle>创建完整备份</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{backups.data?.schedule.passwordSet ? <div className="rounded-md border bg-muted/40 px-3 py-3"><div className="text-sm font-medium">使用已保存的备份密码</div><div className="mt-1 font-mono text-sm text-muted-foreground">{backups.data.schedule.passwordHint || "密码已安全保存"}</div><p className="mt-1 text-xs text-muted-foreground">与定时备份共用同一个恢复密码。</p></div> : <><div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-password">首次设置备份密码</Label><PasswordTools value={password} visible={showCreatePassword} onVisibleChange={setShowCreatePassword} onGenerate={generateCreatePassword} /></div><Input id="backup-password" type={showCreatePassword ? "text" : "password"} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="自己输入或自动生成" /></div><div className="space-y-2"><Label htmlFor="backup-confirm-password">确认备份密码</Label><Input id="backup-confirm-password" type={showCreatePassword ? "text" : "password"} autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} /></div></>}
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"><div><div className="text-sm font-medium">完成后发送到 Telegram</div><div className="text-xs text-muted-foreground">同时发送详细恢复说明和加密附件。</div></div><Switch checked={sendAfterCreate} onCheckedChange={setSendAfterCreate} disabled={!backups.data?.telegramSet} /></div>
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"><div><div className="text-sm font-medium">上传到 Google 云端硬盘</div><div className="text-xs text-muted-foreground">保存加密备份到已连接的云端文件夹。</div></div><Switch checked={driveAfterCreate} onCheckedChange={setDriveAfterCreate} disabled={!backups.data?.googleDrive.connected} /></div>
|
||||||
|
{!backups.data?.schedule.passwordSet && <p className="text-xs text-muted-foreground">首次设置后,手动和定时备份都会使用这个密码。请保存到密码管理器,不要与备份文件放在同一位置。</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={create.isPending}>取消</Button><Button type="button" onClick={submitCreate} disabled={create.isPending}>{create.isPending ? "启动中" : "开始备份"}</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmDialog open={!!deleteName} onOpenChange={(open) => { if (!open) setDeleteName("") }} title="删除这个备份?" description="删除后无法恢复,请确认已经在其他位置保存副本。" confirmText="删除备份" destructive pending={remove.isPending} onConfirm={() => remove.mutate(deleteName)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function UsersSection({ users, permissionGroups, domains }: { users: AdminUser[]; permissionGroups: PermissionGroup[]; domains: Domain[] }) {
|
function UsersSection({ users, permissionGroups, domains }: { users: AdminUser[]; permissionGroups: PermissionGroup[]; domains: Domain[] }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const user = me.data?.user
|
const user = me.data?.user
|
||||||
@@ -703,9 +1055,17 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
|||||||
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||||
const keyword = query.trim().toLowerCase()
|
const keyword = query.trim().toLowerCase()
|
||||||
const knownOwnerIDs = new Set(users.map((item) => item.id))
|
const knownOwnerIDs = new Set(users.map((item) => item.id))
|
||||||
|
const compareMailboxes = (left: MailboxType, right: MailboxType) => {
|
||||||
|
if (left.primary !== right.primary) return left.primary ? -1 : 1
|
||||||
|
return left.address.localeCompare(right.address, "en", { sensitivity: "base" })
|
||||||
|
}
|
||||||
|
const orphanMailboxes = new Map<string, MailboxType[]>()
|
||||||
|
for (const mailbox of mailboxes.filter((item) => !knownOwnerIDs.has(item.userId))) {
|
||||||
|
orphanMailboxes.set(mailbox.userId, [...(orphanMailboxes.get(mailbox.userId) || []), mailbox])
|
||||||
|
}
|
||||||
const mailboxGroups: Array<{ owner?: AdminUser; mailboxes: MailboxType[] }> = [
|
const mailboxGroups: Array<{ owner?: AdminUser; mailboxes: MailboxType[] }> = [
|
||||||
...users.slice().sort(compareAdminUsers).map((owner) => ({ owner, mailboxes: mailboxes.filter((mailbox) => mailbox.userId === owner.id).sort((left, right) => left.address.localeCompare(right.address, "en")) })),
|
...users.slice().sort(compareAdminUsers).map((owner) => ({ owner, mailboxes: mailboxes.filter((mailbox) => mailbox.userId === owner.id).sort(compareMailboxes) })),
|
||||||
...mailboxes.filter((mailbox) => !knownOwnerIDs.has(mailbox.userId)).map((mailbox) => ({ owner: undefined, mailboxes: [mailbox] })),
|
...Array.from(orphanMailboxes.values()).map((items) => ({ owner: undefined, mailboxes: items.sort(compareMailboxes) })),
|
||||||
]
|
]
|
||||||
.filter((group) => group.mailboxes.length > 0)
|
.filter((group) => group.mailboxes.length > 0)
|
||||||
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))
|
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))
|
||||||
@@ -727,8 +1087,11 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
|||||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号或邮箱" className="pl-9" />
|
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号或邮箱" className="pl-9" />
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y overflow-hidden rounded-md border">
|
<div className="divide-y overflow-hidden rounded-md border">
|
||||||
<div className="hidden grid-cols-[minmax(0,1fr)_9rem_8rem] gap-4 bg-muted/40 px-11 py-2 text-xs font-medium text-muted-foreground sm:grid">
|
<div className="hidden grid-cols-[1rem_minmax(0,1fr)_9rem_8rem] gap-3 bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground sm:grid">
|
||||||
<span>归属账号</span><span>权限管理</span><span>子邮箱</span>
|
<span aria-hidden="true" />
|
||||||
|
<span>归属账号</span>
|
||||||
|
<span className="text-center">权限管理</span>
|
||||||
|
<span className="text-center">子邮箱</span>
|
||||||
</div>
|
</div>
|
||||||
{mailboxGroups.map((group) => {
|
{mailboxGroups.map((group) => {
|
||||||
const ownerID = group.owner?.id || group.mailboxes[0].userId
|
const ownerID = group.owner?.id || group.mailboxes[0].userId
|
||||||
@@ -741,8 +1104,8 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
|||||||
<div className="truncate font-medium">{group.owner ? accountPrimaryEmail(group.owner) : group.mailboxes[0].userEmail || "未知账号"}</div>
|
<div className="truncate font-medium">{group.owner ? accountPrimaryEmail(group.owner) : group.mailboxes[0].userEmail || "未知账号"}</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">{group.owner?.displayName || "账号信息不可用"}</div>
|
<div className="truncate text-xs text-muted-foreground">{group.owner?.displayName || "账号信息不可用"}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden sm:block">{group.owner ? <RoleBadge user={group.owner} /> : "-"}</span>
|
<span className="hidden items-center justify-center sm:flex">{group.owner ? <RoleBadge user={group.owner} /> : "-"}</span>
|
||||||
<span className="flex items-center justify-end gap-2 text-sm tabular-nums text-muted-foreground"><span>{group.mailboxes.length} 个</span><span className="hidden text-xs lg:inline">查看邮箱</span></span>
|
<span className="flex items-center justify-center gap-2 text-sm tabular-nums text-muted-foreground"><span>{group.mailboxes.length} 个</span><span className="hidden text-xs lg:inline">查看邮箱</span></span>
|
||||||
</Button>
|
</Button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t bg-muted/20 px-4 py-2 sm:pl-11">
|
<div className="border-t bg-muted/20 px-4 py-2 sm:pl-11">
|
||||||
|
|||||||
+231
-62
@@ -29,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||||
import {
|
import {
|
||||||
@@ -486,7 +487,16 @@ export function MailPage() {
|
|||||||
},
|
},
|
||||||
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
|
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
|
||||||
})
|
})
|
||||||
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
const del = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string; permanent: boolean }) => api.delete(id),
|
||||||
|
onSuccess: async (_, { permanent }) => {
|
||||||
|
setSelectedId(null)
|
||||||
|
setPendingConfirm(null)
|
||||||
|
await refreshMailData()
|
||||||
|
toast({ title: permanent ? "邮件已永久删除" : "邮件已移入已删除" })
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "删除失败", description: error.message }),
|
||||||
|
})
|
||||||
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) })
|
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) })
|
||||||
const cancelScheduledSend = useMutation({
|
const cancelScheduledSend = useMutation({
|
||||||
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
||||||
@@ -814,10 +824,19 @@ export function MailPage() {
|
|||||||
await Promise.all(ids.map((id) => api.star(id, starred)))
|
await Promise.all(ids.map((id) => api.star(id, starred)))
|
||||||
} else if (action === "delete") {
|
} else if (action === "delete") {
|
||||||
await Promise.all(ids.map((id) => api.delete(id)))
|
await Promise.all(ids.map((id) => api.delete(id)))
|
||||||
|
completionTitle = `已永久删除 ${ids.length} 封邮件`
|
||||||
} else {
|
} else {
|
||||||
const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
|
const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
|
||||||
const result = await api.bulkMove(ids, target)
|
const result = await api.bulkMove(ids, target)
|
||||||
completionTitle = result.ok ? result.message : "批量移动部分失败"
|
completionTitle = result.ok
|
||||||
|
? target === "Trash"
|
||||||
|
? `已将 ${result.moved} 封邮件移入已删除`
|
||||||
|
: target === "Spam"
|
||||||
|
? `已将 ${result.moved} 封邮件移入垃圾邮件`
|
||||||
|
: target === "Inbox"
|
||||||
|
? `已将 ${result.moved} 封邮件移回收件箱`
|
||||||
|
: `已归档 ${result.moved} 封邮件`
|
||||||
|
: "批量移动部分失败"
|
||||||
completionDescription = result.ok ? undefined : result.message
|
completionDescription = result.ok ? undefined : result.message
|
||||||
}
|
}
|
||||||
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
|
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
|
||||||
@@ -849,11 +868,14 @@ export function MailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function confirmDeleteMessage(message: MailMessage) {
|
function confirmDeleteMessage(message: MailMessage) {
|
||||||
|
const permanent = message.folder === "Trash"
|
||||||
setPendingConfirm({
|
setPendingConfirm({
|
||||||
title: "删除这封邮件?",
|
title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?",
|
||||||
description: `邮件“${message.subject || "无主题"}”将被删除。`,
|
description: permanent
|
||||||
confirmText: "删除邮件",
|
? `邮件“${message.subject || "无主题"}”将被永久删除,且无法恢复。`
|
||||||
onConfirm: () => del.mutate(message.id),
|
: `邮件“${message.subject || "无主题"}”将移入已删除。`,
|
||||||
|
confirmText: permanent ? "永久删除" : "移入已删除",
|
||||||
|
onConfirm: () => del.mutate({ id: message.id, permanent }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function openCompose(draft?: ComposeDraft) {
|
function openCompose(draft?: ComposeDraft) {
|
||||||
@@ -1669,7 +1691,9 @@ export function MailPage() {
|
|||||||
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
|
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
|
||||||
) : compactMailLayout ? (
|
) : compactMailLayout ? (
|
||||||
<CompactMailView
|
<CompactMailView
|
||||||
|
key={`${mailView}:${mailView === "folder" ? folder : mailView === "label" ? selectedLabelId : selectedExternalAccountId}`}
|
||||||
title={viewTitle}
|
title={viewTitle}
|
||||||
|
currentFolder={mailView === "folder" ? folder : undefined}
|
||||||
icon={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> : undefined}
|
icon={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> : undefined}
|
||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
total={allMessages.length}
|
total={allMessages.length}
|
||||||
@@ -1778,12 +1802,12 @@ export function MailPage() {
|
|||||||
{selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
|
{selectedCountOnPage > 0 && canOrganizeMail && mailView !== "unknown" && (
|
||||||
<div className="flex min-h-10 shrink-0 items-center gap-3 border-b px-3 py-1.5">
|
<div className="flex min-h-10 shrink-0 items-center gap-3 border-b px-3 py-1.5">
|
||||||
<span className="shrink-0 text-[13px] text-muted-foreground">已选 {selectedCountOnPage} 封</span>
|
<span className="shrink-0 text-[13px] text-muted-foreground">已选 {selectedCountOnPage} 封</span>
|
||||||
<BulkActionToolbar pending={bulkPending} currentFolder={folder} folders={folders.data?.items || []} readAction={bulkReadAction} onAction={runBulkAction} onMoveToFolder={runBulkMoveToFolder} />
|
<BulkActionToolbar pending={bulkPending} currentFolder={mailView === "folder" ? folder : undefined} folders={folders.data?.items || []} readAction={bulkReadAction} onAction={runBulkAction} onMoveToFolder={runBulkMoveToFolder} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
{mailMessagesLoading && <MessageSkeleton />}
|
{mailMessagesLoading && <MessageSkeleton />}
|
||||||
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)}
|
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => m.folder === "Trash" ? confirmDeleteMessage(m) : move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail && mailView !== "unknown"} />)}
|
||||||
{!mailMessagesLoading && visibleMessages.length === 0 && <div className="grid min-h-[170px] place-items-center px-8 py-12 text-center text-base text-muted-foreground">{emptyMessage}</div>}
|
{!mailMessagesLoading && visibleMessages.length === 0 && <div className="grid min-h-[170px] place-items-center px-8 py-12 text-center text-base text-muted-foreground">{emptyMessage}</div>}
|
||||||
{!mailMessagesLoading && hasMoreMessages && (
|
{!mailMessagesLoading && hasMoreMessages && (
|
||||||
<div className="border-b p-4 text-center">
|
<div className="border-b p-4 text-center">
|
||||||
@@ -1827,7 +1851,7 @@ export function MailPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}>归档</Button>
|
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}>归档</Button>
|
||||||
))}
|
))}
|
||||||
{mailView !== "external" && mailView !== "unknown" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>删除</Button>}
|
{mailView !== "external" && mailView !== "unknown" && canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MessageMetaPanel
|
<MessageMetaPanel
|
||||||
@@ -2574,6 +2598,7 @@ type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "inbox" |
|
|||||||
|
|
||||||
function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = "read", onAction, onMoveToFolder }: { pending: boolean; currentFolder?: string; folders?: MailFolder[]; readAction?: "read" | "unread"; onAction: (action: BulkAction) => void; onMoveToFolder?: (folderName: string) => void }) {
|
function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = "read", onAction, onMoveToFolder }: { pending: boolean; currentFolder?: string; folders?: MailFolder[]; readAction?: "read" | "unread"; onAction: (action: BulkAction) => void; onMoveToFolder?: (folderName: string) => void }) {
|
||||||
const buttonClass = "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
const buttonClass = "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
const permanentlyDelete = currentFolder === "Trash"
|
||||||
const archiveAction: BulkAction = currentFolder === "Archive" ? "inbox" : "archive"
|
const archiveAction: BulkAction = currentFolder === "Archive" ? "inbox" : "archive"
|
||||||
const archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档"
|
const archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档"
|
||||||
const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts")
|
const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts")
|
||||||
@@ -2602,7 +2627,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="ghost" size="icon" className={cn(buttonClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onAction("trash")} title="移入已删除" aria-label="移入已删除">
|
<Button type="button" variant="ghost" size="icon" className={cn(buttonClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onAction(permanentlyDelete ? "delete" : "trash")} title={permanentlyDelete ? "永久删除" : "移入已删除"} aria-label={permanentlyDelete ? "永久删除" : "移入已删除"}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2784,7 +2809,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
|
|||||||
{canOrganize && (
|
{canOrganize && (
|
||||||
<>
|
<>
|
||||||
<div className="-mx-1 my-1 h-px bg-border" />
|
<div className="-mx-1 my-1 h-px bg-border" />
|
||||||
{item("删除", <Trash2 className="h-4 w-4" />, "delete", true)}
|
{item(message.folder === "Trash" ? "永久删除" : "移入已删除", <Trash2 className="h-4 w-4" />, "delete", true)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2882,6 +2907,7 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
|
|||||||
|
|
||||||
function CompactMailView({
|
function CompactMailView({
|
||||||
title,
|
title,
|
||||||
|
currentFolder,
|
||||||
icon,
|
icon,
|
||||||
messages,
|
messages,
|
||||||
total,
|
total,
|
||||||
@@ -2925,6 +2951,7 @@ function CompactMailView({
|
|||||||
tools,
|
tools,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
|
currentFolder?: string
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactNode
|
||||||
messages: MailMessage[]
|
messages: MailMessage[]
|
||||||
total: number
|
total: number
|
||||||
@@ -3016,7 +3043,7 @@ function CompactMailView({
|
|||||||
{selectedIds.length > 0 ? (
|
{selectedIds.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden text-xs text-muted-foreground min-[380px]:inline">已选 {selectedIds.length} 封</span>
|
<span className="hidden text-xs text-muted-foreground min-[380px]:inline">已选 {selectedIds.length} 封</span>
|
||||||
{canOrganize && <BulkActionToolbar pending={bulkPending} onAction={onBulkAction} />}
|
{canOrganize && <BulkActionToolbar pending={bulkPending} currentFolder={currentFolder} onAction={onBulkAction} />}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -3127,7 +3154,7 @@ function CompactMessageDetail({
|
|||||||
{canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>}
|
{canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canOrganize && <DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" />删除</DropdownMenuItem>}
|
{canOrganize && <DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" />{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</DropdownMenuItem>}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
@@ -3147,7 +3174,7 @@ function CompactMessageDetail({
|
|||||||
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" />删除</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" />{selected.folder === "Trash" ? "永久删除" : "移入已删除"}</Button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}>上一封</Button>
|
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}>上一封</Button>
|
||||||
@@ -3187,23 +3214,41 @@ function CompactMessageDetail({
|
|||||||
|
|
||||||
|
|
||||||
function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) {
|
function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
const [translatedText, setTranslatedText] = React.useState("")
|
const [translatedText, setTranslatedText] = React.useState("")
|
||||||
const [translatedHtml, setTranslatedHtml] = React.useState("")
|
const [translatedHtml, setTranslatedHtml] = React.useState("")
|
||||||
const [showTranslated, setShowTranslated] = React.useState(false)
|
const [showTranslated, setShowTranslated] = React.useState(false)
|
||||||
const [truncated, setTruncated] = React.useState(false)
|
const [truncated, setTruncated] = React.useState(false)
|
||||||
|
const [autoTranslate, setAutoTranslate] = React.useState(() => {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem("newszxcn.mail.auto-translate") !== "false"
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const targetLanguage = normalizeTranslationLanguage(language)
|
const targetLanguage = normalizeTranslationLanguage(language)
|
||||||
const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet])
|
const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet])
|
||||||
const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language)
|
const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language)
|
||||||
|
const translationKey = React.useMemo(() => ["mail-translation", message.externalAccountId || "local", message.id, targetLanguage] as const, [message.externalAccountId, message.id, targetLanguage])
|
||||||
const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText])
|
const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText])
|
||||||
|
const applyTranslation = React.useCallback((result: Awaited<ReturnType<typeof api.translateMessage>>, display = true) => {
|
||||||
|
setTranslatedText(result.translatedText)
|
||||||
|
setTranslatedHtml(result.translatedHtml || "")
|
||||||
|
setTruncated(result.truncated)
|
||||||
|
setShowTranslated(display)
|
||||||
|
}, [])
|
||||||
const translate = useMutation({
|
const translate = useMutation({
|
||||||
mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!),
|
mutationFn: async ({ force = false }: { force?: boolean } = {}) => {
|
||||||
onSuccess: (result) => {
|
if (!force) {
|
||||||
setTranslatedText(result.translatedText)
|
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
|
||||||
setTranslatedHtml(result.translatedHtml || "")
|
if (cached) return cached
|
||||||
setTruncated(result.truncated)
|
}
|
||||||
setShowTranslated(true)
|
const result = message.externalAccountId ? await api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : await api.translateMessage(message.id, targetLanguage!)
|
||||||
|
qc.setQueryData(translationKey, result)
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
|
onSuccess: (result) => applyTranslation(result),
|
||||||
onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3213,8 +3258,30 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
|
|||||||
setShowTranslated(false)
|
setShowTranslated(false)
|
||||||
setTruncated(false)
|
setTruncated(false)
|
||||||
translate.reset()
|
translate.reset()
|
||||||
|
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
|
||||||
|
if (cached) applyTranslation(cached, autoTranslate)
|
||||||
}, [message.id, language])
|
}, [message.id, language])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!autoTranslate || !shouldShow || translate.isPending) return
|
||||||
|
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
|
||||||
|
if (cached) {
|
||||||
|
applyTranslation(cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
translate.mutate({ force: false })
|
||||||
|
}, [autoTranslate, message.id, shouldShow, targetLanguage])
|
||||||
|
|
||||||
|
const changeAutoTranslate = (checked: boolean) => {
|
||||||
|
setAutoTranslate(checked)
|
||||||
|
if (!checked) setShowTranslated(false)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem("newszxcn.mail.auto-translate", String(checked))
|
||||||
|
} catch {
|
||||||
|
// Keep the setting for this session when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(shouldShow || translatedText) && (
|
{(shouldShow || translatedText) && (
|
||||||
@@ -3225,8 +3292,12 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
|
|||||||
{truncated && <span className="ml-1">(内容较长,仅翻译前半部分)</span>}
|
{truncated && <span className="ml-1">(内容较长,仅翻译前半部分)</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
<Switch checked={autoTranslate} onCheckedChange={changeAutoTranslate} aria-label="自动翻译邮件" />
|
||||||
|
自动翻译
|
||||||
|
</label>
|
||||||
{translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>}
|
{translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>}
|
||||||
<Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate()}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button>
|
<Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate({ force: !!translatedText })}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3782,7 +3853,7 @@ function MessageRow({
|
|||||||
<Button type="button" variant="ghost" size="icon" aria-label={archiveLabel} title={archiveLabel} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onArchive() }}>
|
<Button type="button" variant="ghost" size="icon" aria-label={archiveLabel} title={archiveLabel} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onArchive() }}>
|
||||||
<Archive className="h-3.5 w-3.5" />
|
<Archive className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label="移入已删除" title="移入已删除" className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onTrash() }}>
|
<Button type="button" variant="ghost" size="icon" aria-label={message.folder === "Trash" ? "永久删除" : "移入已删除"} title={message.folder === "Trash" ? "永久删除" : "移入已删除"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onTrash() }}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label={message.isRead ? "标为未读" : "标为已读"} title={message.isRead ? "标为未读" : "标为已读"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onToggleRead() }}>
|
<Button type="button" variant="ghost" size="icon" aria-label={message.isRead ? "标为未读" : "标为已读"} title={message.isRead ? "标为未读" : "标为已读"} className={quickButtonClass} onClick={(event) => { event.stopPropagation(); onToggleRead() }}>
|
||||||
@@ -3845,6 +3916,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "")
|
const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "")
|
||||||
const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
|
const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
|
||||||
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null)
|
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null)
|
||||||
|
const [closing, setClosing] = React.useState(false)
|
||||||
const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false)
|
const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false)
|
||||||
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
|
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
|
||||||
const sendStartedRef = React.useRef(false)
|
const sendStartedRef = React.useRef(false)
|
||||||
@@ -3960,7 +4032,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
}, [files])
|
}, [files])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
|
if (!open || closing || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
|
||||||
const payloadKey = JSON.stringify({ ...composePayload, draftId })
|
const payloadKey = JSON.stringify({ ...composePayload, draftId })
|
||||||
if (payloadKey === lastSavedPayloadRef.current) return
|
if (payloadKey === lastSavedPayloadRef.current) return
|
||||||
const timer = window.setTimeout(async () => {
|
const timer = window.setTimeout(async () => {
|
||||||
@@ -3981,7 +4053,7 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [open, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
|
}, [open, closing, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
|
||||||
|
|
||||||
function buildSendWarnings(attachmentsCount: number) {
|
function buildSendWarnings(attachmentsCount: number) {
|
||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
@@ -4033,6 +4105,10 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
const to = splitEmails(toValue)
|
const to = splitEmails(toValue)
|
||||||
const cc = showCc ? splitEmails(ccValue) : []
|
const cc = showCc ? splitEmails(ccValue) : []
|
||||||
const bcc = showBcc ? splitEmails(bccValue) : []
|
const bcc = showBcc ? splitEmails(bccValue) : []
|
||||||
|
if (to.length === 0) {
|
||||||
|
toast({ title: "请填写收件人" })
|
||||||
|
return
|
||||||
|
}
|
||||||
const text = body.text
|
const text = body.text
|
||||||
const html = body.html || plainTextToHtml(text)
|
const html = body.html || plainTextToHtml(text)
|
||||||
const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
|
const payload: SendPayload = { mailboxId: senderMailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments }
|
||||||
@@ -4068,11 +4144,18 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
}
|
}
|
||||||
if (!attachmentsWithinLimit()) return
|
if (!attachmentsWithinLimit()) return
|
||||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
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 } = {
|
const payload: SendPayload & { draftId?: string; sendAt: string } = {
|
||||||
mailboxId: senderMailbox.id,
|
mailboxId: senderMailbox.id,
|
||||||
to: splitEmails(toValue),
|
to,
|
||||||
cc: showCc ? splitEmails(ccValue) : [],
|
cc,
|
||||||
bcc: showBcc ? splitEmails(bccValue) : [],
|
bcc,
|
||||||
subject: subjectValue,
|
subject: subjectValue,
|
||||||
text: body.text,
|
text: body.text,
|
||||||
html: body.html || plainTextToHtml(body.text),
|
html: body.html || plainTextToHtml(body.text),
|
||||||
@@ -4092,15 +4175,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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={(nextOpen) => { if (nextOpen) onOpenChange(true); else void closeCompose() }}>
|
||||||
<DialogContent
|
<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()}
|
onInteractOutside={(event) => event.preventDefault()}
|
||||||
onPointerDownOutside={(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}>
|
<form key={draft?.key || "new"} className="flex min-h-0 min-w-0 flex-1 flex-col" onSubmit={submit}>
|
||||||
<DialogHeader className="border-b px-4 py-3 text-left sm:px-6 sm:py-4">
|
<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">
|
<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>{draftId ? "编辑草稿" : "写信"}</span>
|
||||||
<span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}>
|
<span className={cn("text-xs font-normal", draftStatus === "error" ? "text-destructive" : "text-muted-foreground")}>
|
||||||
@@ -4148,7 +4261,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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
<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" />
|
<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>
|
</ComposeField>
|
||||||
{defaultSignature.isError && (
|
{defaultSignature.isError && (
|
||||||
@@ -4170,10 +4283,25 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
|
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<DialogFooter className="flex flex-row items-center justify-between gap-3 border-t bg-muted/15 px-4 py-3 sm:px-5">
|
||||||
<Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}>取消</Button>
|
{canSend && (
|
||||||
{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>}
|
<div className="flex min-w-0 items-center">
|
||||||
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !senderMailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>}
|
<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-40">
|
||||||
|
<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>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
|
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
|
||||||
@@ -4193,8 +4321,8 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
|
|
||||||
function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) {
|
function ComposeField({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||||
return (
|
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">
|
<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-base font-normal text-foreground sm:w-20">{label}</Label>
|
<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">
|
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
{children}
|
{children}
|
||||||
{action}
|
{action}
|
||||||
@@ -4266,11 +4394,17 @@ type InsertDialogValue = { url: string; text: string; alt: string }
|
|||||||
const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"]
|
const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"]
|
||||||
const composerFontSizeOptions = [
|
const composerFontSizeOptions = [
|
||||||
["2", "小号"],
|
["2", "小号"],
|
||||||
["3", "正文"],
|
["3", "标准"],
|
||||||
["4", "中号"],
|
["4", "中号"],
|
||||||
["5", "大号"],
|
["5", "大号"],
|
||||||
] as const
|
] as const
|
||||||
const composerFontSizeValueByKey: Record<string, string> = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" }
|
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 composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const
|
||||||
const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const
|
const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const
|
||||||
const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"]
|
const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"]
|
||||||
@@ -4312,7 +4446,15 @@ function fontLabel(value: string) {
|
|||||||
|
|
||||||
function fontSizeLabel(value: string) {
|
function fontSizeLabel(value: string) {
|
||||||
const normalized = normalizeFontSize(value) || "3"
|
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"]) {
|
function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) {
|
||||||
@@ -4438,8 +4580,10 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
content: composerInitialHtml(defaultValue, defaultHtml),
|
content: composerInitialHtml(defaultValue, defaultHtml),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
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": "正文",
|
"aria-label": "正文",
|
||||||
|
spellcheck: "true",
|
||||||
|
autocorrect: "on",
|
||||||
},
|
},
|
||||||
handlePaste(view, event) {
|
handlePaste(view, event) {
|
||||||
const clipboard = event.clipboardData
|
const clipboard = event.clipboardData
|
||||||
@@ -4493,6 +4637,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3"
|
const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3"
|
||||||
const activeColor = textStyleAttributes?.color || ""
|
const activeColor = textStyleAttributes?.color || ""
|
||||||
const activeHighlight = textStyleAttributes?.backgroundColor || ""
|
const activeHighlight = textStyleAttributes?.backgroundColor || ""
|
||||||
|
const characterCount = editor?.getText().replace(/\s/g, "").length || 0
|
||||||
void selectionVersion
|
void selectionVersion
|
||||||
|
|
||||||
function applyFont(font: string) {
|
function applyFont(font: string) {
|
||||||
@@ -4560,9 +4705,9 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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} />
|
<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().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>
|
<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" />
|
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||||
@@ -4579,7 +4724,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>
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />分隔线</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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)} />
|
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
|
||||||
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -4604,9 +4749,24 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{formatOpen && (
|
{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>
|
<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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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}>
|
<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}>
|
||||||
@@ -4637,7 +4797,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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("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("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>
|
<ToolbarButton label="下划线" active={editor?.isActive("underline")} disabled={!editor} onClick={() => editor?.chain().focus().toggleUnderline().run()}><Underline className="h-4 w-4" /></ToolbarButton>
|
||||||
@@ -4672,30 +4832,39 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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("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="有序列表" 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>
|
<DropdownMenu>
|
||||||
<ToolbarButton label="增加缩进" disabled={!editor?.can().sinkListItem("listItem")} onClick={() => editor?.chain().focus().sinkListItem("listItem").run()}><IndentIncrease className="h-4 w-4" /></ToolbarButton>
|
<DropdownMenuTrigger asChild>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<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}>
|
||||||
<ToolbarButton label="左对齐" active={editor?.isActive({ textAlign: "left" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("left").run()}><AlignLeft className="h-4 w-4" /></ToolbarButton>
|
<Ellipsis className="h-4 w-4" />
|
||||||
<ToolbarButton label="居中" active={editor?.isActive({ textAlign: "center" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("center").run()}><AlignCenter className="h-4 w-4" /></ToolbarButton>
|
</Button>
|
||||||
<ToolbarButton label="右对齐" active={editor?.isActive({ textAlign: "right" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("right").run()}><AlignRight className="h-4 w-4" /></ToolbarButton>
|
</DropdownMenuTrigger>
|
||||||
<ToolbarButton label="引用" active={editor?.isActive("blockquote")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToolbarButton>
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
<ToolbarButton label="代码块" active={editor?.isActive("codeBlock")} disabled={!editor} onClick={() => editor?.chain().focus().toggleCodeBlock().run()}><Code2 className="h-4 w-4" /></ToolbarButton>
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"composer-editor relative flex min-h-[240px] flex-1 border-b focus-within:bg-card/40 sm:min-h-[280px]",
|
"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-[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",
|
"[&_.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_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"
|
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>
|
</div>
|
||||||
{files.length > 0 && (
|
{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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{files.map((file, index) => (
|
{files.map((file, index) => (
|
||||||
<Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal">
|
<Badge key={`${file.name}-${file.size}-${index}`} variant="outline" className="h-8 gap-2 rounded-md px-2 font-normal">
|
||||||
@@ -4717,7 +4886,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>邮件预览</DialogTitle>
|
<DialogTitle>邮件预览</DialogTitle>
|
||||||
</DialogHeader>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
ca-certificates tzdata sqlite3 supervisor nginx \
|
ca-certificates tzdata sqlite3 supervisor nginx \
|
||||||
postfix postfix-sqlite \
|
postfix postfix-sqlite \
|
||||||
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \
|
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \
|
||||||
rspamd
|
rspamd zstd openssl
|
||||||
|
|
||||||
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
|
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
|
||||||
COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
|
COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
|
||||||
@@ -43,6 +43,7 @@ COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html
|
|||||||
COPY deploy/all-in-one/supervisord.conf /etc/supervisor/conf.d/lanqin.conf
|
COPY deploy/all-in-one/supervisord.conf /etc/supervisor/conf.d/lanqin.conf
|
||||||
COPY deploy/all-in-one/nginx.conf /etc/nginx/sites-enabled/default
|
COPY deploy/all-in-one/nginx.conf /etc/nginx/sites-enabled/default
|
||||||
COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh
|
COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh
|
||||||
|
COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml
|
||||||
|
|
||||||
COPY deploy/postfix/main.cf /etc/postfix/main.cf
|
COPY deploy/postfix/main.cf /etc/postfix/main.cf
|
||||||
COPY deploy/postfix/master.cf /etc/postfix/master.cf
|
COPY deploy/postfix/master.cf /etc/postfix/master.cf
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
|
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
|
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
|
||||||
|
COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml
|
||||||
EXPOSE 8080 465 587
|
EXPOSE 8080 465 587
|
||||||
CMD ["lanqin-api"]
|
CMD ["lanqin-api"]
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
|
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
|
||||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||||
|
LANQIN_BACKUP_SOURCE_DIR: /backup-source
|
||||||
|
LANQIN_BACKUP_DIR: /backups
|
||||||
ports:
|
ports:
|
||||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||||
@@ -17,6 +19,9 @@ services:
|
|||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
- ./dkim:/var/lib/rspamd/dkim
|
- ./dkim:/var/lib/rspamd/dkim
|
||||||
- ./certs:/certs:ro
|
- ./certs:/certs:ro
|
||||||
|
- ./.env:/backup-source/.env:ro
|
||||||
|
- ./docker-compose.yml:/backup-source/docker-compose.yml:ro
|
||||||
|
- ./backups:/backups
|
||||||
labels:
|
labels:
|
||||||
com.centurylinklabs.watchtower.enable: "true"
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# NewSzxcn 完整备份与灾难恢复
|
||||||
|
|
||||||
|
## 后台创建并推送到 Telegram
|
||||||
|
|
||||||
|
1. 使用系统管理员登录 NewSzxcn 后台。
|
||||||
|
2. 在“系统设置 -> 通知”绑定 Telegram Bot Token 和私聊 Chat ID,并发送测试通知。
|
||||||
|
3. 打开“备份与恢复”。
|
||||||
|
4. 在“定时备份与 Telegram 推送”中选择每 3、5、7、30 天,或填写 1 至 365 天的自定义周期。
|
||||||
|
5. 备份 Chat ID 留空时沿用邮件通知私聊;也可以填写一个仅管理员可见的私有群组 Chat ID,将邮件通知与备份文件分开。Bot 必须已经加入该群组。
|
||||||
|
6. 填写服务器公网 IP。备份密码可以自己输入,也可以点击“生成 24 位”;必须另外保存到 1Password 等密码管理器。
|
||||||
|
7. 开启“自动创建并推送”,保存设置。
|
||||||
|
8. 首次配置建议点击“创建备份”,勾选“完成后发送到 Telegram”,确认机器人能收到说明消息和 `.tar.zst.enc` 加密附件。
|
||||||
|
|
||||||
|
Telegram 消息包含邮局域名、服务器 IP、系统版本、已有域名、管理员账号、普通用户账号、邮箱账号、文件大小、SHA-256 和恢复步骤。已有域名只列域名,不附加账号身份。
|
||||||
|
|
||||||
|
消息不会包含管理员密码、用户密码或备份密码。数据库只保存登录密码哈希,不能反向读取明文;恢复后账号继续使用原登录密码。备份密码与加密附件也不应保存在同一个 Telegram 会话中。
|
||||||
|
|
||||||
|
## 原服务器失联后的恢复
|
||||||
|
|
||||||
|
准备一台新的 Debian 或 Ubuntu 服务器。先把 Telegram 中的加密附件原样上传到新服务器的 `/root/` 目录,请不要解压、修改或固定填写某个示例文件名。备份日期和版本号每次可能不同。
|
||||||
|
|
||||||
|
确认文件已经上传后,首次执行官方脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本显示“尚未安装”管理菜单后,输入 `2`,选择“备份恢复”。在恢复完成后,以后需要管理系统时才使用 `ns` 打开管理菜单。
|
||||||
|
|
||||||
|
在“尚未安装”菜单选择:
|
||||||
|
|
||||||
|
```text
|
||||||
|
==================================================
|
||||||
|
NewSzxcn Email 管理面板
|
||||||
|
==================================================
|
||||||
|
状态:尚未安装
|
||||||
|
--------------------------------------------------
|
||||||
|
1. 一键安装 NewSzxcn Email
|
||||||
|
2. 备份恢复
|
||||||
|
3. 退出
|
||||||
|
==================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
进入备份恢复菜单后,输入 `1` 选择“本地上传”。脚本会自动扫描 `/root/newszxcn-backup-*`:只有一份时直接选中;多份时按日期从新到旧显示为 `1、2、3` 等序号,输入对应序号,例如输入 `1` 恢复第 1 份。没有找到时才要求手动输入完整路径。选定后输入备份密码,脚本会检查压缩包路径、SQLite 完整性和必要目录,再启动服务。
|
||||||
|
|
||||||
|
恢复完成后:
|
||||||
|
|
||||||
|
1. 如果新服务器 IP 改变,更新邮件主机的 A/AAAA、邮件域名的 MX/SPF,以及服务商处的 PTR 记录。
|
||||||
|
2. 检查 DKIM 和 DMARC;DKIM 私钥已随备份恢复,但 DNS 仍应核对。
|
||||||
|
3. 检查 TLS 证书是否适用于当前主机名,必要时重新签发。
|
||||||
|
4. 登录网页并测试收信、发信、IMAP、POP3 和 SMTP Submission。
|
||||||
|
5. 打开“备份与恢复”,重新测试 Telegram 推送。
|
||||||
|
|
||||||
|
## 备份内容
|
||||||
|
|
||||||
|
完整备份包括 SQLite 数据库、附件、Maildir 原始邮件、DKIM 私钥、TLS 证书、`.env`、Compose 配置、版本清单和 SHA-256 校验文件。备份使用 Zstandard 压缩,并以 AES-256-CBC、PBKDF2 200000 次迭代和 SHA-256 加密。
|
||||||
|
|
||||||
|
Telegram 适合保存体积较小的应急副本,不应作为唯一备份位置。超过 Telegram 发送上限的文件请从后台下载,并保存到 Google 云端硬盘、另一台服务器、对象存储或离线磁盘。
|
||||||
|
|
||||||
|
## Google 云端硬盘
|
||||||
|
|
||||||
|
后台“备份与恢复”支持将同一份加密备份保存到 Google 云端硬盘。系统只申请 `drive.file` 权限,只能管理由 NewSzxcn 创建的文件,不会读取云端硬盘中的其他文件。
|
||||||
|
|
||||||
|
1. 在 Google Cloud Console 创建项目并启用 Google Drive API。
|
||||||
|
2. 配置 OAuth 同意屏幕,再创建“Web 应用”类型的 OAuth 客户端。
|
||||||
|
3. 授权重定向 URI 填写 `https://你的邮局域名/api/admin/backups/google-drive/callback`,必须与后台系统设置中的公开访问地址一致。
|
||||||
|
4. 在后台填写 OAuth 客户端 ID、客户端密钥和云端文件夹名称,先保存或直接点击“连接 Google”。
|
||||||
|
5. 在 Google 授权页面确认后会自动返回“备份与恢复”,状态显示“已连接”。
|
||||||
|
6. 可开启“用于定时备份”,也可在创建备份或已有备份菜单中单独上传。
|
||||||
|
|
||||||
|
OAuth 客户端密钥和刷新令牌会使用服务器内部密钥加密保存。Google 云端硬盘中只保存 `.tar.zst.enc` 加密备份,备份密码仍应单独保管。
|
||||||
+298
-3
@@ -28,6 +28,7 @@ NewSzxcn Email 管理命令
|
|||||||
|
|
||||||
menu 显示安装与运维菜单
|
menu 显示安装与运维菜单
|
||||||
install 首次安装;已有安装会先完整备份再重新安装
|
install 首次安装;已有安装会先完整备份再重新安装
|
||||||
|
restore 从完整备份目录或压缩包恢复到新服务器
|
||||||
update 备份数据库并更新到最新版
|
update 备份数据库并更新到最新版
|
||||||
repair 检查并修复现有安装
|
repair 检查并修复现有安装
|
||||||
status 查看容器与健康状态
|
status 查看容器与健康状态
|
||||||
@@ -161,6 +162,25 @@ ensure_cli_alias() {
|
|||||||
success "快捷命令已创建:输入 ns 可打开管理菜单。"
|
success "快捷命令已创建:输入 ns 可打开管理菜单。"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_cli_command() {
|
||||||
|
local source_dir tmp
|
||||||
|
[[ -x "${CLI_PATH}" ]] && return 0
|
||||||
|
install -d -m 0755 "$(dirname "${CLI_PATH}")"
|
||||||
|
source_dir="$(script_dir || true)"
|
||||||
|
if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != /dev/fd/* && -f "${source_dir}/install.sh" ]]; then
|
||||||
|
install -m 0755 "${source_dir}/install.sh" "${CLI_PATH}"
|
||||||
|
else
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
if ! curl -fsSL "${RAW_BASE}/install.sh" -o "${tmp}" || ! bash -n "${tmp}"; then
|
||||||
|
rm -f "${tmp}"
|
||||||
|
warn "未能安装管理命令;完成安装后可重新运行官方脚本修复。"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
install -m 0755 "${tmp}" "${CLI_PATH}"
|
||||||
|
rm -f "${tmp}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
refresh_assets() {
|
refresh_assets() {
|
||||||
stage_assets
|
stage_assets
|
||||||
apply_staged_assets
|
apply_staged_assets
|
||||||
@@ -1077,6 +1097,277 @@ do_install() {
|
|||||||
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
|
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱后台配置指南。"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_restore_source() {
|
||||||
|
local source="$1"
|
||||||
|
[[ -f "${source}/.env" ]] || { warn "备份缺少 .env。"; return 1; }
|
||||||
|
[[ -f "${source}/docker-compose.yml" ]] || { warn "备份缺少 docker-compose.yml。"; return 1; }
|
||||||
|
[[ -s "${source}/data/lanqin.db" ]] || { warn "备份缺少数据库 data/lanqin.db。"; return 1; }
|
||||||
|
[[ -d "${source}/mail" ]] || { warn "备份缺少 mail 邮件目录。"; return 1; }
|
||||||
|
[[ -d "${source}/dkim" ]] || { warn "备份缺少 dkim 密钥目录。"; return 1; }
|
||||||
|
[[ -d "${source}/certs" ]] || { warn "备份缺少 certs 证书目录。"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_restore_database() {
|
||||||
|
local database="$1" result
|
||||||
|
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
log "正在安装 SQLite 校验工具..."
|
||||||
|
install_packages sqlite3
|
||||||
|
fi
|
||||||
|
result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
|
||||||
|
[[ "${result}" == "ok" ]] || { warn "备份数据库完整性检查未通过。"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
locate_extracted_restore_root() {
|
||||||
|
local root="$1" candidate
|
||||||
|
if validate_restore_source "${root}" >/dev/null 2>&1; then
|
||||||
|
printf '%s' "${root}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
candidate="$(find "${root}" -mindepth 1 -maxdepth 2 -type f -name .env -print -quit 2>/dev/null || true)"
|
||||||
|
[[ -n "${candidate}" ]] || return 1
|
||||||
|
candidate="$(dirname "${candidate}")"
|
||||||
|
validate_restore_source "${candidate}" >/dev/null 2>&1 || return 1
|
||||||
|
printf '%s' "${candidate}"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_restore_menu() {
|
||||||
|
prompt_text '\n==================================================\n'
|
||||||
|
prompt_text ' NewSzxcn Email 备份恢复\n'
|
||||||
|
prompt_text '==================================================\n'
|
||||||
|
prompt_text '1. 本地上传\n'
|
||||||
|
prompt_text '2. 返回上一级\n'
|
||||||
|
prompt_text '==================================================\n'
|
||||||
|
prompt_text '请先将原始加密备份上传到新服务器的 /root/ 目录,不要解压。\n'
|
||||||
|
prompt_text '系统会自动检测 /root/ 目录中的 NewSzxcn 备份文件。\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
do_restore_menu() {
|
||||||
|
local choice
|
||||||
|
render_restore_menu
|
||||||
|
choice="$(prompt_menu_choice "1" "2")" || return 1
|
||||||
|
case "${choice}" in
|
||||||
|
1) do_restore_backup ;;
|
||||||
|
2) success "已返回,未作任何修改。" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_restore_password() {
|
||||||
|
local password="${LANQIN_RESTORE_PASSWORD:-}"
|
||||||
|
if [[ -z "${password}" ]] && has_tty; then
|
||||||
|
read -r -s -p "备份密码: " password </dev/tty
|
||||||
|
printf '\n' >/dev/tty
|
||||||
|
fi
|
||||||
|
[[ -n "${password}" ]] || fail "加密备份必须提供备份密码。"
|
||||||
|
(( ${#password} >= 8 && ${#password} <= 1024 )) || fail "备份密码必须为 8 至 1024 个字符。"
|
||||||
|
[[ "${password}" != *$'\n'* && "${password}" != *$'\r'* ]] || fail "备份密码不能包含换行。"
|
||||||
|
printf '%s' "${password}"
|
||||||
|
}
|
||||||
|
|
||||||
|
discover_restore_backups() {
|
||||||
|
local search_dir="${LANQIN_RESTORE_SEARCH_DIR:-/root}" path
|
||||||
|
local -a matches=()
|
||||||
|
[[ -d "${search_dir}" ]] || return 0
|
||||||
|
while IFS= read -r path; do
|
||||||
|
case "${path}" in
|
||||||
|
*.tar.zst.enc|*.tar.zst|*.tar.gz|*.tgz|*.tar) matches+=("${path}") ;;
|
||||||
|
esac
|
||||||
|
done < <(find "${search_dir}" -maxdepth 1 -type f -name 'newszxcn-backup-*' -print 2>/dev/null | LC_ALL=C sort -r)
|
||||||
|
(( ${#matches[@]} > 0 )) || return 0
|
||||||
|
printf '%s\n' "${matches[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
select_restore_source() {
|
||||||
|
local selection="${LANQIN_RESTORE_SELECTION:-}" path index
|
||||||
|
local -a backups=()
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[[ -n "${path}" ]] && backups+=("${path}")
|
||||||
|
done < <(discover_restore_backups)
|
||||||
|
|
||||||
|
if (( ${#backups[@]} == 1 )); then
|
||||||
|
prompt_text "[检测] 已找到备份:${backups[0]}\n"
|
||||||
|
printf '%s' "${backups[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if (( ${#backups[@]} > 1 )); then
|
||||||
|
prompt_text "[检测] 在 /root/ 找到 ${#backups[@]} 份备份:\n"
|
||||||
|
for index in "${!backups[@]}"; do
|
||||||
|
prompt_text "$((index + 1)). ${backups[index]}\n"
|
||||||
|
done
|
||||||
|
prompt_text "$(( ${#backups[@]} + 1 )). 手动输入其他路径\n"
|
||||||
|
if [[ -z "${selection}" ]] && has_tty; then
|
||||||
|
read -r -p "请输入要恢复的备份序号 [1]: " selection </dev/tty
|
||||||
|
fi
|
||||||
|
selection="${selection:-1}"
|
||||||
|
if [[ "${selection}" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#backups[@]} )); then
|
||||||
|
prompt_text "[选择] 将使用第 ${selection} 份备份开始恢复。\n"
|
||||||
|
printf '%s' "${backups[selection-1]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[[ "${selection}" == "$(( ${#backups[@]} + 1 ))" ]] || fail "备份序号无效。"
|
||||||
|
else
|
||||||
|
prompt_text "[提示] /root/ 目录没有检测到 NewSzxcn 备份,请手动输入路径。\n"
|
||||||
|
fi
|
||||||
|
if has_tty; then
|
||||||
|
read -r -p "备份文件完整路径: " path </dev/tty
|
||||||
|
else
|
||||||
|
path="${LANQIN_RESTORE_SOURCE:-}"
|
||||||
|
fi
|
||||||
|
printf '%s' "${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
archive_has_unsafe_paths() {
|
||||||
|
awk '
|
||||||
|
BEGIN { bad=0 }
|
||||||
|
{
|
||||||
|
if (substr($0, 1, 1) == "/") bad=1
|
||||||
|
count=split($0, parts, "/")
|
||||||
|
for (i=1; i<=count; i++) if (parts[i] == "..") bad=1
|
||||||
|
}
|
||||||
|
END { exit bad ? 0 : 1 }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
archive_has_unsafe_types() {
|
||||||
|
awk '
|
||||||
|
BEGIN { bad=0 }
|
||||||
|
/^[[:space:]]*$/ { next }
|
||||||
|
{
|
||||||
|
type=substr($0, 1, 1)
|
||||||
|
if (type != "-" && type != "d") bad=1
|
||||||
|
}
|
||||||
|
END { exit bad ? 0 : 1 }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_restore_archive() {
|
||||||
|
local source="$1" destination="$2" password decrypted
|
||||||
|
case "${source}" in
|
||||||
|
*.tar.zst.enc)
|
||||||
|
command -v openssl >/dev/null 2>&1 || install_packages openssl
|
||||||
|
command -v zstd >/dev/null 2>&1 || install_packages zstd
|
||||||
|
password="$(prompt_restore_password)"
|
||||||
|
decrypted="${destination}/backup.tar.zst"
|
||||||
|
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -in "${source}" -out "${decrypted}" -pass fd:3 3<<<"${password}" 2>/dev/null; then
|
||||||
|
fail "备份密码错误或加密备份已损坏。"
|
||||||
|
fi
|
||||||
|
if zstd -dc "${decrypted}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then
|
||||||
|
fail "备份压缩包包含不安全路径,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
if zstd -dc "${decrypted}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then
|
||||||
|
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
zstd -dc "${decrypted}" 2>/dev/null | tar -xf - -C "${destination}" \
|
||||||
|
|| fail "加密备份无法解压,请检查文件和密码。"
|
||||||
|
rm -f "${decrypted}"
|
||||||
|
;;
|
||||||
|
*.tar.zst)
|
||||||
|
command -v zstd >/dev/null 2>&1 || install_packages zstd
|
||||||
|
if zstd -dc "${source}" 2>/dev/null | tar -tf - | archive_has_unsafe_paths; then
|
||||||
|
fail "备份压缩包包含不安全路径,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
if zstd -dc "${source}" 2>/dev/null | tar -tvf - | archive_has_unsafe_types; then
|
||||||
|
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
zstd -dc "${source}" 2>/dev/null | tar -xf - -C "${destination}" \
|
||||||
|
|| fail "Zstandard 备份无法解压。"
|
||||||
|
;;
|
||||||
|
*.tar|*.tar.gz|*.tgz)
|
||||||
|
if tar -tf "${source}" | archive_has_unsafe_paths; then
|
||||||
|
fail "备份压缩包包含不安全路径,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
if tar -tvf "${source}" | archive_has_unsafe_types; then
|
||||||
|
fail "备份压缩包包含链接或特殊文件,已拒绝恢复。"
|
||||||
|
fi
|
||||||
|
tar -xf "${source}" -C "${destination}" || fail "备份压缩包无法解压。"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "不支持的备份格式;请选择 .tar.zst.enc、.tar.zst、.tar.gz、.tgz 或 .tar。"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
do_restore_backup() {
|
||||||
|
local source="${LANQIN_RESTORE_SOURCE:-}" extracted="" restore_root staging image_ref image nginx_backup=""
|
||||||
|
! installation_configured || fail "当前服务器已经存在安装配置;为防止覆盖运行数据,只能在空白新服务器执行完整恢复。"
|
||||||
|
[[ -n "${source}" ]] || source="$(select_restore_source)"
|
||||||
|
[[ -n "${source}" ]] || fail "请提供备份目录或备份压缩包路径。"
|
||||||
|
source="$(readlink -f "${source}" 2>/dev/null || true)"
|
||||||
|
[[ -e "${source}" ]] || fail "备份不存在:${source}"
|
||||||
|
|
||||||
|
if [[ -d "${source}" ]]; then
|
||||||
|
restore_root="${source}"
|
||||||
|
else
|
||||||
|
command -v tar >/dev/null 2>&1 || install_packages tar
|
||||||
|
extracted="$(mktemp -d)"
|
||||||
|
extract_restore_archive "${source}" "${extracted}"
|
||||||
|
restore_root="$(locate_extracted_restore_root "${extracted}" || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${restore_root}" ]] || ! validate_restore_source "${restore_root}"; then
|
||||||
|
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
|
||||||
|
fail "这不是可恢复的 NewSzxcn 完整备份。"
|
||||||
|
fi
|
||||||
|
if ! validate_restore_database "${restore_root}/data/lanqin.db"; then
|
||||||
|
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
|
||||||
|
fail "备份数据库已损坏,未写入任何 NewSzxcn 数据。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
staging="${INSTALL_DIR}.restore-staging-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
[[ ! -e "${INSTALL_DIR}" || -z "$(find "${INSTALL_DIR}" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]] \
|
||||||
|
|| fail "${INSTALL_DIR} 已有文件,已取消恢复以免覆盖数据。"
|
||||||
|
rm -rf "${staging}"
|
||||||
|
install -d -m 0700 "${staging}"
|
||||||
|
cp -a "${restore_root}/." "${staging}/"
|
||||||
|
[[ -n "${extracted}" ]] && rm -rf "${extracted}"
|
||||||
|
rm -rf "${INSTALL_DIR}"
|
||||||
|
mv "${staging}" "${INSTALL_DIR}"
|
||||||
|
chmod 0600 "${INSTALL_DIR}/.env"
|
||||||
|
|
||||||
|
if [[ -f "${NGINX_CONFIG}" ]]; then
|
||||||
|
nginx_backup="$(mktemp)"
|
||||||
|
cp -a "${NGINX_CONFIG}" "${nginx_backup}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! (
|
||||||
|
refresh_assets
|
||||||
|
ensure_update_token
|
||||||
|
ensure_admin_email_config
|
||||||
|
configure_runtime_bindings
|
||||||
|
ensure_docker
|
||||||
|
configure_firewall
|
||||||
|
prepare_directories
|
||||||
|
log "正在拉取恢复所需的 NewSzxcn Email 镜像..."
|
||||||
|
compose pull
|
||||||
|
image_ref="$(env_value LANQIN_IMAGE || true)"
|
||||||
|
image_ref="${image_ref:-ghcr.io/zxyszx/newszxcn-email:latest}"
|
||||||
|
image="$(docker image inspect --format '{{.Id}}' "${image_ref}" 2>/dev/null || true)"
|
||||||
|
[[ -n "${image}" ]] || fail "无法检查恢复数据库:镜像不存在。"
|
||||||
|
sqlite_integrity_check "${INSTALL_DIR}/data/lanqin.db" "${image}" || fail "备份数据库完整性检查未通过,服务未启动。"
|
||||||
|
log "备份检查通过,正在启动服务..."
|
||||||
|
compose up -d --remove-orphans
|
||||||
|
wait_for_health 90 || fail "恢复后的服务未通过健康检查,请执行 newszxcn-email logs。"
|
||||||
|
configure_web_mode
|
||||||
|
); then
|
||||||
|
warn "恢复未完成,正在清理本次未成功的安装。"
|
||||||
|
compose down --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
rm -rf "${INSTALL_DIR}"
|
||||||
|
if [[ -n "${nginx_backup}" && -f "${nginx_backup}" ]]; then
|
||||||
|
cp -a "${nginx_backup}" "${NGINX_CONFIG}"
|
||||||
|
elif [[ -f "${NGINX_CONFIG}" ]]; then
|
||||||
|
rm -f "${NGINX_CONFIG}"
|
||||||
|
fi
|
||||||
|
rm -f "${nginx_backup}"
|
||||||
|
if nginx -t >/dev/null 2>&1; then
|
||||||
|
systemctl reload nginx >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fail "恢复失败,原始备份文件未修改;修复问题后可重新执行备份恢复。"
|
||||||
|
fi
|
||||||
|
rm -f "${nginx_backup}"
|
||||||
|
ensure_cli_alias
|
||||||
|
generate_guide >/dev/null || warn "数据已恢复,但配置指南生成失败,可稍后执行 newszxcn-email guide。"
|
||||||
|
success "备份恢复完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||||
|
warn "如果服务器 IP 已更换,请更新 A、MX、SPF、PTR,并重新检查 TLS 证书。"
|
||||||
|
}
|
||||||
|
|
||||||
do_update() {
|
do_update() {
|
||||||
require_installation
|
require_installation
|
||||||
ensure_docker
|
ensure_docker
|
||||||
@@ -1503,7 +1794,8 @@ render_uninstalled_menu() {
|
|||||||
prompt_text '状态:尚未安装\n'
|
prompt_text '状态:尚未安装\n'
|
||||||
prompt_text '--------------------------------------------------\n'
|
prompt_text '--------------------------------------------------\n'
|
||||||
prompt_text '1. 一键安装 NewSzxcn Email\n'
|
prompt_text '1. 一键安装 NewSzxcn Email\n'
|
||||||
prompt_text '0. 退出\n'
|
prompt_text '2. 备份恢复\n'
|
||||||
|
prompt_text '3. 退出\n'
|
||||||
prompt_text '==================================================\n'
|
prompt_text '==================================================\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1541,10 +1833,11 @@ do_menu() {
|
|||||||
local default_choice="2" public_url="" choice status version
|
local default_choice="2" public_url="" choice status version
|
||||||
if ! installation_configured; then
|
if ! installation_configured; then
|
||||||
render_uninstalled_menu
|
render_uninstalled_menu
|
||||||
choice="$(prompt_menu_choice "1" "1")" || return 1
|
choice="$(prompt_menu_choice "1" "3")" || return 1
|
||||||
case "${choice}" in
|
case "${choice}" in
|
||||||
0) success "已退出,未作任何修改。" ;;
|
3) success "已退出,未作任何修改。" ;;
|
||||||
1) do_install ;;
|
1) do_install ;;
|
||||||
|
2) do_restore_menu ;;
|
||||||
esac
|
esac
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
@@ -1581,6 +1874,7 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${EUID}" -eq 0 ]]; then
|
if [[ "${EUID}" -eq 0 ]]; then
|
||||||
|
ensure_cli_command
|
||||||
ensure_cli_alias
|
ensure_cli_alias
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1588,6 +1882,7 @@ case "${COMMAND}" in
|
|||||||
help|-h|--help) usage ;;
|
help|-h|--help) usage ;;
|
||||||
menu) require_root; require_curl; do_menu ;;
|
menu) require_root; require_curl; do_menu ;;
|
||||||
install) require_root; require_curl; do_install ;;
|
install) require_root; require_curl; do_install ;;
|
||||||
|
restore) require_root; require_curl; do_restore_menu ;;
|
||||||
update) require_root; require_curl; do_update ;;
|
update) require_root; require_curl; do_update ;;
|
||||||
repair) require_root; require_curl; do_repair_install ;;
|
repair) require_root; require_curl; do_repair_install ;;
|
||||||
status) require_root; require_curl; do_status ;;
|
status) require_root; require_curl; do_status ;;
|
||||||
|
|||||||
+152
-3
@@ -159,6 +159,8 @@ test_menu_rendering() (
|
|||||||
[[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing"
|
[[ "${output}" == *'NewSzxcn Email 管理面板'* ]] || fail_test "uninstalled menu title missing"
|
||||||
[[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing"
|
[[ "${output}" == *'状态:尚未安装'* ]] || fail_test "uninstalled menu status missing"
|
||||||
[[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing"
|
[[ "${output}" == *'1. 一键安装 NewSzxcn Email'* ]] || fail_test "uninstalled menu install action missing"
|
||||||
|
[[ "${output}" == *'2. 备份恢复'* ]] || fail_test "uninstalled menu restore action missing"
|
||||||
|
[[ "${output}" == *'3. 退出'* ]] || fail_test "uninstalled menu exit action missing"
|
||||||
[[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action"
|
[[ "${output}" != *'更新系统'* ]] || fail_test "uninstalled menu exposes update action"
|
||||||
[[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action"
|
[[ "${output}" != *'卸载服务'* ]] || fail_test "uninstalled menu exposes uninstall action"
|
||||||
|
|
||||||
@@ -186,12 +188,15 @@ test_menu_dispatch() (
|
|||||||
mkdir -p "${INSTALL_DIR}"
|
mkdir -p "${INSTALL_DIR}"
|
||||||
prompt_text() { :; }
|
prompt_text() { :; }
|
||||||
do_install() { printf 'install\n' > "${action_file}"; }
|
do_install() { printf 'install\n' > "${action_file}"; }
|
||||||
|
do_restore_menu() { printf 'restore-menu\n' > "${action_file}"; }
|
||||||
|
|
||||||
do_menu
|
do_menu
|
||||||
grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install"
|
grep -Fq 'install' "${action_file}" || fail_test "uninstalled menu did not dispatch install"
|
||||||
if (LANQIN_MENU_ACTION=2 do_menu >/dev/null 2>&1); then
|
LANQIN_MENU_ACTION=2
|
||||||
fail_test "uninstalled menu accepted unavailable update action"
|
do_menu
|
||||||
fi
|
grep -Fq 'restore-menu' "${action_file}" || fail_test "uninstalled menu did not dispatch restore"
|
||||||
|
LANQIN_MENU_ACTION=3
|
||||||
|
do_menu >/dev/null
|
||||||
|
|
||||||
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
|
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${INSTALL_DIR}/.env"
|
||||||
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
|
||||||
@@ -430,6 +435,144 @@ test_cli_alias_safety() (
|
|||||||
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
|
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_restore_source_validation() (
|
||||||
|
local temp_dir
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
mkdir -p "${temp_dir}/data" "${temp_dir}/mail" "${temp_dir}/dkim" "${temp_dir}/certs"
|
||||||
|
printf 'config\n' > "${temp_dir}/.env"
|
||||||
|
printf 'services: {}\n' > "${temp_dir}/docker-compose.yml"
|
||||||
|
sqlite3 "${temp_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
|
||||||
|
validate_restore_source "${temp_dir}" || fail_test "valid restore source rejected"
|
||||||
|
validate_restore_database "${temp_dir}/data/lanqin.db" || fail_test "valid restore database rejected"
|
||||||
|
printf 'damaged\n' > "${temp_dir}/data/lanqin.db"
|
||||||
|
if validate_restore_database "${temp_dir}/data/lanqin.db" >/dev/null 2>&1; then
|
||||||
|
fail_test "damaged restore database accepted"
|
||||||
|
fi
|
||||||
|
rm -f "${temp_dir}/data/lanqin.db"
|
||||||
|
if validate_restore_source "${temp_dir}" >/dev/null 2>&1; then
|
||||||
|
fail_test "restore source without database accepted"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
test_restore_menu_rendering_and_dispatch() (
|
||||||
|
local output action_file LANQIN_MENU_ACTION=1
|
||||||
|
action_file="$(mktemp)"
|
||||||
|
prompt_text() { printf '%b' "$1"; }
|
||||||
|
output="$(render_restore_menu)"
|
||||||
|
[[ "${output}" == *'NewSzxcn Email 备份恢复'* ]] || fail_test "restore menu title missing"
|
||||||
|
[[ "${output}" == *'1. 本地上传'* ]] || fail_test "restore local upload action missing"
|
||||||
|
[[ "${output}" == *'2. 返回上一级'* ]] || fail_test "restore back action missing"
|
||||||
|
[[ "${output}" == *'自动检测 /root/'* ]] || fail_test "restore automatic discovery hint missing"
|
||||||
|
prompt_text() { :; }
|
||||||
|
do_restore_backup() { printf 'restore\n' > "${action_file}"; }
|
||||||
|
do_restore_menu
|
||||||
|
grep -Fq 'restore' "${action_file}" || fail_test "restore menu did not dispatch local upload"
|
||||||
|
LANQIN_MENU_ACTION=2
|
||||||
|
do_restore_menu >/dev/null
|
||||||
|
unset LANQIN_MENU_ACTION
|
||||||
|
)
|
||||||
|
|
||||||
|
test_restore_backup_discovery() (
|
||||||
|
local temp_dir output selected
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
LANQIN_RESTORE_SEARCH_DIR="${temp_dir}"
|
||||||
|
touch "${temp_dir}/unrelated.tar.zst.enc"
|
||||||
|
output="$(discover_restore_backups)"
|
||||||
|
[[ -z "${output}" ]] || fail_test "unrelated archive was discovered"
|
||||||
|
|
||||||
|
touch "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc"
|
||||||
|
selected="$(select_restore_source)"
|
||||||
|
assert_eq "${temp_dir}/newszxcn-backup-20260810-120000-1.2.30.tar.zst.enc" "${selected}" "single discovered restore backup"
|
||||||
|
|
||||||
|
touch "${temp_dir}/newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc"
|
||||||
|
touch "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc"
|
||||||
|
output="$(discover_restore_backups)"
|
||||||
|
assert_eq "newszxcn-backup-20260812-120000-1.2.32.tar.zst.enc" "$(printf '%s\n' "${output}" | head -n 1 | xargs basename)" "newest restore backup ordering"
|
||||||
|
LANQIN_RESTORE_SELECTION=2
|
||||||
|
selected="$(select_restore_source)"
|
||||||
|
assert_eq "${temp_dir}/newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" "${selected}" "selected discovered restore backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
test_encrypted_restore_archive() (
|
||||||
|
local temp_dir source_dir archive extracted password='RestorePassword123!'
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
source_dir="${temp_dir}/source/newszxcn-email"
|
||||||
|
archive="${temp_dir}/newszxcn-backup.tar.zst.enc"
|
||||||
|
extracted="${temp_dir}/extracted"
|
||||||
|
mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "${extracted}"
|
||||||
|
printf 'config\n' > "${source_dir}/.env"
|
||||||
|
printf 'services: {}\n' > "${source_dir}/docker-compose.yml"
|
||||||
|
sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
|
||||||
|
zstd() {
|
||||||
|
if [[ "$*" == '-q -c' ]]; then
|
||||||
|
gzip -c
|
||||||
|
elif [[ "$1" == '-dc' ]]; then
|
||||||
|
gzip -dc "$2"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
tar -C "${temp_dir}/source" -cf - newszxcn-email | zstd -q -c | \
|
||||||
|
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}"
|
||||||
|
LANQIN_RESTORE_PASSWORD="${password}" extract_restore_archive "${archive}" "${extracted}"
|
||||||
|
validate_restore_source "${extracted}/newszxcn-email" || fail_test "encrypted restore archive extraction failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
test_failed_full_restore_cleans_partial_install() (
|
||||||
|
local temp_dir source_dir archive password='RestorePassword123!'
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
source_dir="${temp_dir}/source/newszxcn-backup"
|
||||||
|
archive="${temp_dir}/newszxcn-backup-20260812-120000-1.2.31.tar.zst.enc"
|
||||||
|
INSTALL_DIR="${temp_dir}/install"
|
||||||
|
NGINX_CONFIG="${temp_dir}/nginx/newszxcn.conf"
|
||||||
|
CERT_DIR="${temp_dir}/certs"
|
||||||
|
LANQIN_RESTORE_SOURCE="${archive}"
|
||||||
|
LANQIN_RESTORE_PASSWORD="${password}"
|
||||||
|
mkdir -p "${source_dir}/data" "${source_dir}/mail" "${source_dir}/dkim" "${source_dir}/certs" "$(dirname "${NGINX_CONFIG}")"
|
||||||
|
printf 'LANQIN_PUBLIC_BASE_URL=https://mail.example.com\n' > "${source_dir}/.env"
|
||||||
|
printf 'services: {}\n' > "${source_dir}/docker-compose.yml"
|
||||||
|
sqlite3 "${source_dir}/data/lanqin.db" 'CREATE TABLE restore_test (id INTEGER PRIMARY KEY);'
|
||||||
|
zstd() {
|
||||||
|
if [[ "$*" == '-q -c' ]]; then
|
||||||
|
gzip -c
|
||||||
|
elif [[ "$1" == '-dc' ]]; then
|
||||||
|
gzip -dc "$2"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
tar -C "${temp_dir}/source" -cf - newszxcn-backup | zstd -q -c | \
|
||||||
|
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 -out "${archive}" -pass fd:3 3<<<"${password}"
|
||||||
|
refresh_assets() { return 0; }
|
||||||
|
ensure_update_token() { return 0; }
|
||||||
|
ensure_admin_email_config() { return 0; }
|
||||||
|
configure_runtime_bindings() { return 0; }
|
||||||
|
ensure_docker() { return 0; }
|
||||||
|
configure_firewall() { return 0; }
|
||||||
|
prepare_directories() { return 0; }
|
||||||
|
compose() {
|
||||||
|
case "$1" in
|
||||||
|
pull) return 1 ;;
|
||||||
|
down) return 0 ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (do_restore_backup >/dev/null 2>&1); then
|
||||||
|
fail_test "failed full restore unexpectedly succeeded"
|
||||||
|
fi
|
||||||
|
[[ ! -e "${INSTALL_DIR}" ]] || fail_test "failed restore left a partial installation"
|
||||||
|
[[ -f "${archive}" ]] || fail_test "failed restore removed the original encrypted backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
test_restore_archive_path_validation() (
|
||||||
|
printf 'safe/path\n' | archive_has_unsafe_paths && fail_test "safe archive path rejected"
|
||||||
|
printf '../escape\n' | archive_has_unsafe_paths || fail_test "parent archive path accepted"
|
||||||
|
printf '/absolute\n' | archive_has_unsafe_paths || fail_test "absolute archive path accepted"
|
||||||
|
printf '%s\n' '-rw------- root/root 1 2026-08-12 00:00 safe' | archive_has_unsafe_types && fail_test "regular archive file rejected"
|
||||||
|
printf '%s\n' 'drwx------ root/root 0 2026-08-12 00:00 safe/' | archive_has_unsafe_types && fail_test "archive directory rejected"
|
||||||
|
printf '%s\n' 'lrwxrwxrwx root/root 0 2026-08-12 00:00 unsafe -> /etc' | archive_has_unsafe_types || fail_test "archive symlink accepted"
|
||||||
|
)
|
||||||
|
|
||||||
test_compose_runtime_image_pin() (
|
test_compose_runtime_image_pin() (
|
||||||
local temp_dir calls
|
local temp_dir calls
|
||||||
temp_dir="$(mktemp -d)"
|
temp_dir="$(mktemp -d)"
|
||||||
@@ -707,6 +850,12 @@ test_offline_database_backup
|
|||||||
test_guide_generation
|
test_guide_generation
|
||||||
test_acme_cron_detection
|
test_acme_cron_detection
|
||||||
test_cli_alias_safety
|
test_cli_alias_safety
|
||||||
|
test_restore_source_validation
|
||||||
|
test_restore_menu_rendering_and_dispatch
|
||||||
|
test_restore_backup_discovery
|
||||||
|
test_encrypted_restore_archive
|
||||||
|
test_failed_full_restore_cleans_partial_install
|
||||||
|
test_restore_archive_path_validation
|
||||||
test_compose_runtime_image_pin
|
test_compose_runtime_image_pin
|
||||||
test_update_snapshot_restore
|
test_update_snapshot_restore
|
||||||
test_snapshot_restores_absent_optional_files
|
test_snapshot_restores_absent_optional_files
|
||||||
|
|||||||
Reference in New Issue
Block a user