Compare commits

...

5 Commits

Author SHA1 Message Date
zxyszx 06388465a2 fix: default mailbox selector while loading
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:45:49 +08:00
zxyszx e6504ef492 fix: expand all-mailbox selector
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:22:37 +08:00
zxyszx 39ff9ce01d fix: support batched mail imports
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 13:08:37 +08:00
zxyszx df50f8b3ef fix: reset mailbox view on refresh
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 12:55:21 +08:00
zxyszx 397ce51800 fix: make web updates survive container restart
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 04:14:36 +08:00
16 changed files with 231 additions and 38 deletions
+13
View File
@@ -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)
+13
View File
@@ -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)
+21
View File
@@ -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)
+18
View File
@@ -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)
+25
View File
@@ -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)
+2
View File
@@ -62,6 +62,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。 超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。 更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
### 命令行更新 ### 命令行更新
+1 -1
View File
@@ -1 +1 @@
1.2.6 1.2.11
@@ -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)
}
+1
View File
@@ -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)
+1 -2
View File
@@ -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
View File
@@ -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 && (
+1
View File
@@ -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;
+1
View File
@@ -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;