Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06388465a2 | |||
| e6504ef492 | |||
| 39ff9ce01d | |||
| df50f8b3ef | |||
| 397ce51800 |
@@ -0,0 +1,13 @@
|
|||||||
|
## 本次更新
|
||||||
|
|
||||||
|
### 优化邮箱选择器动态宽度
|
||||||
|
|
||||||
|
- 选择“全部邮箱”、正在加载或尚未注册邮箱时,邮箱选择框占满侧栏整行,不再为隐藏按钮预留空白。
|
||||||
|
- 选择具体邮箱后,选择框自动向左收窄,并在右侧显示复制邮箱地址按钮。
|
||||||
|
- 邮箱下拉菜单始终与当前选择框等宽,避免展开前后宽度不一致。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.9...v1.2.10](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.9...v1.2.10)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## 本次更新
|
||||||
|
|
||||||
|
### 移除“加载邮箱”过渡文案
|
||||||
|
|
||||||
|
- 刷新邮箱页面时,邮箱选择器从第一帧开始显示“全部邮箱”。
|
||||||
|
- 邮箱列表在后台加载,不再显示不存在的“加载邮箱...”状态。
|
||||||
|
- 接口确认当前账号没有邮箱后,选择器才显示“未注册邮箱”。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.10...v1.2.11](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.10...v1.2.11)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
## 本次更新
|
||||||
|
|
||||||
|
### 修复网页更新出现 502
|
||||||
|
|
||||||
|
- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。
|
||||||
|
- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。
|
||||||
|
- 前端遇到更新期间的 `502`、`503`、`504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。
|
||||||
|
- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。
|
||||||
|
|
||||||
|
### 更新说明
|
||||||
|
|
||||||
|
- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。
|
||||||
|
- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。
|
||||||
|
- 从 `v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。
|
||||||
|
- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## 本次更新
|
||||||
|
|
||||||
|
### 修复邮箱选择器默认状态
|
||||||
|
|
||||||
|
- 没有注册邮箱时,邮箱选择器明确显示“未注册邮箱”。
|
||||||
|
- 已有邮箱时,每次打开或刷新邮箱页面默认进入“全部邮箱”的“收件箱”,不再恢复上次选择的单个邮箱。
|
||||||
|
- 用户仍可在当前页面正常切换全部邮箱或单个邮箱,切换后统一返回收件箱。
|
||||||
|
|
||||||
|
### 修复邮箱下拉菜单宽度
|
||||||
|
|
||||||
|
- 邮箱下拉菜单现在与上方选择框等宽,不再向右多出一截。
|
||||||
|
- 搜索框、全部邮箱和邮箱地址均在相同宽度内对齐显示。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.7...v1.2.8](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.7...v1.2.8)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## 本次更新
|
||||||
|
|
||||||
|
### 修复邮件导入 413
|
||||||
|
|
||||||
|
- 修复 all-in-one 和多容器部署中,内部 Nginx 使用默认 `1 MB` 上传限制,导致单封稍大的 EML 邮件也导入失败的问题。
|
||||||
|
- API 上传入口现在允许最多 `50 MB` 的单批请求;单封邮件仍遵循系统设置中的邮件大小限制。
|
||||||
|
- 遇到 `413 Request Entity Too Large` 时改为显示明确的中文提示。
|
||||||
|
|
||||||
|
### 支持大批量导入
|
||||||
|
|
||||||
|
- 一次选择多封 EML/MBOX 后,网页会按最多 20 个文件、约 `32 MB` 自动分批上传,无需用户手动拆分文件。
|
||||||
|
- 每批成功后立即保存邮件;后续批次失败不会删除已经成功导入的邮件。
|
||||||
|
- 导入结束后统一显示成功和跳过数量,并刷新当前邮件列表。
|
||||||
|
|
||||||
|
### 保留历史邮件时间
|
||||||
|
|
||||||
|
- 导入时继续保留邮件头中的原始 `Date` 时间。
|
||||||
|
- 收件箱按邮件历史时间倒序显示,最新邮件排列在最前。
|
||||||
|
- 新增回归测试,验证批量导入后不受文件选择或处理顺序影响。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 已通过 Go API 全量测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.8...v1.2.9](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.8...v1.2.9)
|
||||||
@@ -62,6 +62,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
|||||||
|
|
||||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||||
|
|
||||||
|
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||||
|
|
||||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||||
|
|
||||||
### 命令行更新
|
### 命令行更新
|
||||||
|
|||||||
@@ -65,20 +65,21 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
|||||||
t.Fatalf("owner login=%d", code)
|
t.Fatalf("owner login=%d", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
||||||
|
olderEML := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: older imported message\r\nDate: Mon, 1 Jan 2024 12:00:00 +0000\r\nMessage-ID: <older-imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import")
|
||||||
var imported struct {
|
var imported struct {
|
||||||
Imported int `json:"imported"`
|
Imported int `json:"imported"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
}
|
}
|
||||||
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &imported); code != http.StatusOK || imported.Imported != 1 || imported.Skipped != 0 {
|
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml, "older.eml": olderEML}, &imported); code != http.StatusOK || imported.Imported != 2 || imported.Skipped != 0 {
|
||||||
t.Fatalf("import code=%d response=%+v", code, imported)
|
t.Fatalf("import code=%d response=%+v", code, imported)
|
||||||
}
|
}
|
||||||
|
|
||||||
var list struct {
|
var list struct {
|
||||||
Items []MailMessage `json:"items"`
|
Items []MailMessage `json:"items"`
|
||||||
}
|
}
|
||||||
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].Subject != "imported message" {
|
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 2 || list.Items[0].Subject != "imported message" || list.Items[1].Subject != "older imported message" {
|
||||||
t.Fatalf("list code=%d items=%+v", code, list.Items)
|
t.Fatalf("list code=%d items=%+v", code, list.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ func TestMailImportExportAndOwnership(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(zr.File) != 1 {
|
if len(zr.File) != 2 {
|
||||||
t.Fatalf("zip entries=%d", len(zr.File))
|
t.Fatalf("zip entries=%d", len(zr.File))
|
||||||
}
|
}
|
||||||
entry, err := zr.File[0].Open()
|
entry, err := zr.File[0].Open()
|
||||||
|
|||||||
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.triggerUpdateService(r.Context()); err != nil {
|
|
||||||
a.log.Error("trigger system update", "error", err)
|
|
||||||
respondError(w, http.StatusBadGateway, "failed to start update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -94,6 +88,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
"targetVersion": info.LatestVersion,
|
"targetVersion": info.LatestVersion,
|
||||||
"message": "更新已启动,服务会在完成后自动恢复",
|
"message": "更新已启动,服务会在完成后自动恢复",
|
||||||
})
|
})
|
||||||
|
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||||
@@ -175,7 +170,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 10 * time.Minute,
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
@@ -192,6 +187,18 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
|
||||||
|
go func() {
|
||||||
|
// Let the accepted response reach the browser before Watchtower replaces this container.
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.triggerUpdateService(ctx); err != nil {
|
||||||
|
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||||
backupDir := filepath.Join(a.config().DataDir, "backups")
|
backupDir := filepath.Join(a.config().DataDir, "backups")
|
||||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||||
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
defer releaseServer.Close()
|
defer releaseServer.Close()
|
||||||
|
|
||||||
var updateRequests atomic.Int32
|
var updateRequests atomic.Int32
|
||||||
|
updateStarted := make(chan struct{}, 1)
|
||||||
|
releaseUpdate := make(chan struct{})
|
||||||
|
var releaseUpdateOnce sync.Once
|
||||||
|
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
|
||||||
|
defer releaseBlockedUpdate()
|
||||||
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
t.Errorf("update method = %s", r.Method)
|
t.Errorf("update method = %s", r.Method)
|
||||||
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
t.Errorf("authorization = %q", got)
|
t.Errorf("authorization = %q", got)
|
||||||
}
|
}
|
||||||
updateRequests.Add(1)
|
updateRequests.Add(1)
|
||||||
|
updateStarted <- struct{}{}
|
||||||
|
<-releaseUpdate
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
defer updateServer.Close()
|
defer updateServer.Close()
|
||||||
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
t.Fatalf("unexpected version response: %+v", version)
|
t.Fatalf("unexpected version response: %+v", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
var update map[string]any
|
type updateResponse struct {
|
||||||
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
|
code int
|
||||||
t.Fatalf("update code=%d response=%v", code, update)
|
err error
|
||||||
}
|
}
|
||||||
if updateRequests.Load() != 1 {
|
response := make(chan updateResponse, 1)
|
||||||
t.Fatalf("update requests=%d", updateRequests.Load())
|
go func() {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
|
||||||
|
if err != nil {
|
||||||
|
response <- updateResponse{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.AddCookie(admin.cookie)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
response <- updateResponse{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
response <- updateResponse{code: resp.StatusCode}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case result := <-response:
|
||||||
|
if result.err != nil || result.code != http.StatusAccepted {
|
||||||
|
t.Fatalf("update response=%+v", result)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
releaseBlockedUpdate()
|
||||||
|
t.Fatal("update response waited for container replacement")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-updateStarted:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("scheduled update request did not start")
|
||||||
|
}
|
||||||
|
releaseBlockedUpdate()
|
||||||
|
if got := updateRequests.Load(); got != 1 {
|
||||||
|
t.Fatalf("update requests=%d", got)
|
||||||
}
|
}
|
||||||
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||||
if err != nil || len(backups) != 1 {
|
if err != nil || len(backups) != 1 {
|
||||||
|
|||||||
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
|||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
setUpdatePhase("starting")
|
setUpdatePhase("starting")
|
||||||
const result = await api.updateSystem()
|
const targetVersion = version.data?.latestVersion
|
||||||
|
let result: Awaited<ReturnType<typeof api.updateSystem>>
|
||||||
|
try {
|
||||||
|
result = await api.updateSystem()
|
||||||
|
} catch (error) {
|
||||||
|
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
|
||||||
|
result = {
|
||||||
|
ok: true,
|
||||||
|
currentVersion,
|
||||||
|
targetVersion,
|
||||||
|
message: "更新请求已发送,正在等待服务恢复",
|
||||||
|
}
|
||||||
|
}
|
||||||
setUpdatePhase("restarting")
|
setUpdatePhase("restarting")
|
||||||
await waitForUpdatedService(result.targetVersion)
|
await waitForUpdatedService(result.targetVersion)
|
||||||
return result
|
return result
|
||||||
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
|
|||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUpdateConnectionInterruption(error: unknown) {
|
||||||
|
if (!(error instanceof Error)) return false
|
||||||
|
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ async function uploadForm<T>(path: string, form: FormData): Promise<T> {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal })
|
const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (res.status === 413) throw new Error("导入文件过大,请减少单次导入数量后重试")
|
||||||
let message = `${res.status} ${res.statusText}`
|
let message = `${res.status} ${res.statusText}`
|
||||||
try { const body = await res.json(); message = body.error || message } catch {}
|
try { const body = await res.json(); message = body.error || message } catch {}
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
|
|||||||
@@ -69,8 +69,7 @@ const exactTranslations: Record<string, Translation> = {
|
|||||||
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
|
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
|
||||||
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
||||||
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
|
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
|
||||||
"加载邮箱...": { "zh-TW": "載入信箱...", en: "Loading mailboxes..." },
|
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
|
||||||
"未创建邮箱": { "zh-TW": "尚未建立信箱", en: "No mailbox created" },
|
|
||||||
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
||||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||||
|
|||||||
+51
-18
@@ -88,10 +88,28 @@ const filterLabels: Record<MailFilter, string> = {
|
|||||||
|
|
||||||
const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false }
|
const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false }
|
||||||
const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch }
|
const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch }
|
||||||
const mailboxSelectionStorageVersion = "2"
|
const mailImportBatchBytes = 32 * 1024 * 1024
|
||||||
|
const mailImportBatchFiles = 20
|
||||||
const mailCompactBreakpoint = 768
|
const mailCompactBreakpoint = 768
|
||||||
const mailDetailBreakpoint = 768
|
const mailDetailBreakpoint = 768
|
||||||
|
|
||||||
|
function buildMailImportBatches(files: File[]) {
|
||||||
|
const batches: File[][] = []
|
||||||
|
let batch: File[] = []
|
||||||
|
let batchBytes = 0
|
||||||
|
for (const file of files) {
|
||||||
|
if (batch.length > 0 && (batch.length >= mailImportBatchFiles || batchBytes + file.size > mailImportBatchBytes)) {
|
||||||
|
batches.push(batch)
|
||||||
|
batch = []
|
||||||
|
batchBytes = 0
|
||||||
|
}
|
||||||
|
batch.push(file)
|
||||||
|
batchBytes += file.size
|
||||||
|
}
|
||||||
|
if (batch.length > 0) batches.push(batch)
|
||||||
|
return batches
|
||||||
|
}
|
||||||
|
|
||||||
function useMaxViewportWidth(maxWidth: number) {
|
function useMaxViewportWidth(maxWidth: number) {
|
||||||
const [matches, setMatches] = React.useState(false)
|
const [matches, setMatches] = React.useState(false)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -122,10 +140,7 @@ export function MailPage() {
|
|||||||
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
|
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
|
||||||
const sidebarCollapsed = false
|
const sidebarCollapsed = false
|
||||||
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
|
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
|
||||||
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => {
|
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
|
||||||
if (localStorage.getItem("lanqin:selected-mailbox-version") !== mailboxSelectionStorageVersion) return "all"
|
|
||||||
return localStorage.getItem("lanqin:selected-mailbox") || "all"
|
|
||||||
})
|
|
||||||
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
|
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
|
||||||
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
|
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
|
||||||
const [externalFolder, setExternalFolder] = React.useState("INBOX")
|
const [externalFolder, setExternalFolder] = React.useState("INBOX")
|
||||||
@@ -190,6 +205,7 @@ export function MailPage() {
|
|||||||
const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || ""
|
const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || ""
|
||||||
const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined)
|
const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined)
|
||||||
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
|
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
|
||||||
|
const showMailboxCopy = !!selectedMailbox && !isAllMailboxSelected
|
||||||
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId && canReadMail })
|
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId && canReadMail })
|
||||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
|
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
|
||||||
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
|
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
|
||||||
@@ -491,14 +507,19 @@ export function MailPage() {
|
|||||||
}
|
}
|
||||||
if (!selectedMailboxId || (selectedMailboxId !== "all" && !items.some((item) => item.id === selectedMailboxId))) {
|
if (!selectedMailboxId || (selectedMailboxId !== "all" && !items.some((item) => item.id === selectedMailboxId))) {
|
||||||
setSelectedMailboxId("all")
|
setSelectedMailboxId("all")
|
||||||
|
setSelectedExternalAccountId("")
|
||||||
|
setFolder("Inbox")
|
||||||
|
setMailView("folder")
|
||||||
|
setSelectedLabelId("")
|
||||||
|
setSelectedId(null)
|
||||||
|
setMailFilter("all")
|
||||||
}
|
}
|
||||||
}, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId])
|
}, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId)
|
localStorage.removeItem("lanqin:selected-mailbox")
|
||||||
else localStorage.removeItem("lanqin:selected-mailbox")
|
localStorage.removeItem("lanqin:selected-mailbox-version")
|
||||||
localStorage.setItem("lanqin:selected-mailbox-version", mailboxSelectionStorageVersion)
|
}, [])
|
||||||
}, [selectedMailboxId])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedId(null)
|
setSelectedId(null)
|
||||||
@@ -1111,8 +1132,22 @@ export function MailPage() {
|
|||||||
if (files.length === 0 || !selectedMailbox) return
|
if (files.length === 0 || !selectedMailbox) return
|
||||||
setImportingMail(true)
|
setImportingMail(true)
|
||||||
try {
|
try {
|
||||||
const result = await api.importMail(files, { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" })
|
const batches = buildMailImportBatches(files)
|
||||||
|
const target = { mailboxId: selectedMailbox.id, folder: mailView === "folder" ? folder : "Inbox" }
|
||||||
|
const result = { imported: 0, skipped: 0, errors: [] as string[] }
|
||||||
|
for (const batch of batches) {
|
||||||
|
try {
|
||||||
|
const current = await api.importMail(batch, target)
|
||||||
|
result.imported += current.imported
|
||||||
|
result.skipped += current.skipped
|
||||||
|
result.errors.push(...current.errors)
|
||||||
|
} catch (error) {
|
||||||
|
result.skipped += batch.length
|
||||||
|
result.errors.push(error instanceof Error ? error.message : "导入请求失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
await refreshMailData()
|
await refreshMailData()
|
||||||
|
if (result.imported === 0 && result.errors.length > 0) throw new Error(result.errors[0])
|
||||||
toast({
|
toast({
|
||||||
title: `已导入 ${result.imported} 封邮件`,
|
title: `已导入 ${result.imported} 封邮件`,
|
||||||
description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `:${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`,
|
description: result.skipped > 0 ? `${result.skipped} 封未能导入${result.errors[0] ? `:${result.errors[0]}` : ""}` : `已保存到 ${mailView === "folder" ? viewTitle : "收件箱"}`,
|
||||||
@@ -1182,7 +1217,7 @@ export function MailPage() {
|
|||||||
onLanguageChange={setLanguage}
|
onLanguageChange={setLanguage}
|
||||||
onSettings={openSettings}
|
onSettings={openSettings}
|
||||||
/>
|
/>
|
||||||
<div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : "grid grid-cols-[minmax(0,1fr)_2rem]")}>
|
<div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : showMailboxCopy ? "grid grid-cols-[minmax(0,1fr)_2rem]" : "grid grid-cols-1")}>
|
||||||
<MailboxSwitcher
|
<MailboxSwitcher
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
mailboxes={mailboxList.data?.items || []}
|
mailboxes={mailboxList.data?.items || []}
|
||||||
@@ -1192,17 +1227,15 @@ export function MailPage() {
|
|||||||
unreadCount={mailboxUnreadCount}
|
unreadCount={mailboxUnreadCount}
|
||||||
onSelect={switchMailbox}
|
onSelect={switchMailbox}
|
||||||
/>
|
/>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && showMailboxCopy && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn("h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background", isAllMailboxSelected && "invisible pointer-events-none")}
|
className="h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background"
|
||||||
onClick={copyCurrentMailbox}
|
onClick={copyCurrentMailbox}
|
||||||
disabled={!selectedMailbox || isAllMailboxSelected}
|
disabled={!selectedMailbox}
|
||||||
aria-label="复制邮箱地址"
|
aria-label="复制邮箱地址"
|
||||||
aria-hidden={isAllMailboxSelected}
|
|
||||||
tabIndex={isAllMailboxSelected ? -1 : 0}
|
|
||||||
title="复制邮箱地址"
|
title="复制邮箱地址"
|
||||||
>
|
>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
@@ -3219,7 +3252,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
|
|||||||
const [mailboxQuery, setMailboxQuery] = React.useState("")
|
const [mailboxQuery, setMailboxQuery] = React.useState("")
|
||||||
const isAllSelected = selectedMailboxId === "all"
|
const isAllSelected = selectedMailboxId === "all"
|
||||||
const mailboxUnavailable = loading || mailboxes.length === 0
|
const mailboxUnavailable = loading || mailboxes.length === 0
|
||||||
const displayAddress = loading ? "加载邮箱..." : mailboxes.length === 0 ? "未创建邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
|
const displayAddress = loading ? "全部邮箱" : mailboxes.length === 0 ? "未注册邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
|
||||||
const selectedUnreadCount = isAllSelected ? unreadCount : (selectedMailbox?.unreadCount ?? unreadCount)
|
const selectedUnreadCount = isAllSelected ? unreadCount : (selectedMailbox?.unreadCount ?? unreadCount)
|
||||||
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
||||||
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
|
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
|
||||||
@@ -3248,7 +3281,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
|
|||||||
align="start"
|
align="start"
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[calc(100vw-32px)] p-1",
|
"max-w-[calc(100vw-32px)] p-1",
|
||||||
collapsed ? "w-[204px]" : "w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)] min-w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)]"
|
collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-[var(--radix-dropdown-menu-trigger-width)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mailboxes.length > 0 && (
|
{mailboxes.length > 0 && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
client_max_body_size 50m;
|
||||||
proxy_pass http://127.0.0.1:8080/api/;
|
proxy_pass http://127.0.0.1:8080/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ server {
|
|||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
client_max_body_size 50m;
|
||||||
proxy_pass http://api:8080/api/;
|
proxy_pass http://api:8080/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
Reference in New Issue
Block a user