From a11e1cd2f1f855636ed00a41b800b07c3510a5ce Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:57:41 +0800 Subject: [PATCH] fix: refine mail export and mailbox deletion --- .github/release-notes/v1.2.13.md | 30 +++++ VERSION | 2 +- apps/api/internal/app/admin_handlers.go | 23 +--- .../internal/app/mail_transfer_handlers.go | 96 ++++++++++++---- .../app/mail_transfer_handlers_test.go | 104 +++++++++++++++++- apps/api/internal/app/mailbox_delete_test.go | 34 ++++++ apps/api/internal/app/open_api_handlers.go | 23 +--- apps/web/src/lib/api.ts | 3 +- apps/web/src/pages/mail.tsx | 27 ++--- 9 files changed, 260 insertions(+), 82 deletions(-) create mode 100644 .github/release-notes/v1.2.13.md create mode 100644 apps/api/internal/app/mailbox_delete_test.go diff --git a/.github/release-notes/v1.2.13.md b/.github/release-notes/v1.2.13.md new file mode 100644 index 0000000..66d5690 --- /dev/null +++ b/.github/release-notes/v1.2.13.md @@ -0,0 +1,30 @@ +## 本次更新 + +### 修复选中邮件下载 + +- 勾选邮件后点击下载,只导出当前选中的邮件;未勾选时仍导出当前邮箱视图。 +- 下载接口继续校验邮箱归属和当前视图,不能通过邮件 ID 下载其他账号的邮件。 + +### 改进 EML 文件信息 + +- 压缩包内文件名改为“邮件标题 (接收日期).eml”,保留中文标题,不再出现乱码式名称。 +- EML 文件修改时间使用邮件接收时间,与邮件详情中的“接收时间”保持一致。 +- 邮件原始正文、附件和邮件头保持不变;同名文件会自动添加序号。 + +### 调整邮箱与设置交互 + +- 移除邮箱页面头部的后台管理图标,后台管理入口仅保留在设置页面。 +- 设置页面提前加载并在加载完成后切换,避免首次点击齿轮时出现整页加载闪烁。 +- 邮箱页面不再预加载后台管理代码,减少无用网络请求和解析开销。 + +### 修复最后一个邮箱删除 + +- 管理员现在可以删除账号的最后一个邮箱,账号本身和登录状态不会被删除。 +- 删除邮箱流程的错误提示改为简体中文。 + +### 验证 + +- 已通过完整 Go 测试、Go 静态检查、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。 +- 已实测管理员删除最后一个邮箱、设置页面切换和后台入口显示。 + +**完整更新日志**:[v1.2.12...v1.2.13](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.12...v1.2.13) diff --git a/VERSION b/VERSION index f2ae0b4..0b1f1ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.12 +1.2.13 diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index a6beccd..d41fc5c 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -730,26 +730,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - current := currentUser(r) - var owner string - if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil { - respondError(w, http.StatusNotFound, "mailbox not found") - return - } - var count int - if current != nil && owner == current.ID { - if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil { - respondError(w, http.StatusInternalServerError, "failed to check mailbox") - return - } - if count <= 1 { - badRequest(w, errors.New("cannot delete your last mailbox")) - return - } - } rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to load mailbox messages") + respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败") return } messageIDs := []string{} @@ -765,12 +748,12 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) { } res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to delete mailbox") + respondError(w, http.StatusInternalServerError, "删除邮箱失败") return } affected, _ := res.RowsAffected() if affected == 0 { - respondError(w, http.StatusNotFound, "mailbox not found") + respondError(w, http.StatusNotFound, "邮箱不存在或已被删除") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true}) diff --git a/apps/api/internal/app/mail_transfer_handlers.go b/apps/api/internal/app/mail_transfer_handlers.go index 26ad258..1f605ff 100644 --- a/apps/api/internal/app/mail_transfer_handlers.go +++ b/apps/api/internal/app/mail_transfer_handlers.go @@ -13,13 +13,20 @@ import ( "net/http" "os" "path/filepath" - "regexp" "strings" + "time" + "unicode" ) const maxMailImportBytes int64 = 256 << 20 -var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) +const maxSelectedMailExport = 200 + +type exportedMessage struct { + raw []byte + subject string + date time.Time +} func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) { ids, err := a.exportMessageIDs(r) @@ -43,19 +50,21 @@ func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) { zw := zip.NewWriter(w) usedNames := make(map[string]int, len(ids)) - for index, id := range ids { - raw, subject, err := a.rawMessageForExport(r.Context(), id) + for _, id := range ids { + message, err := a.rawMessageForExport(r.Context(), id) if err != nil { _ = zw.Close() return } - entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames) - entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate}) + entryName := uniqueExportFilename(exportMessageFilename(message.subject, message.date), usedNames) + header := &zip.FileHeader{Name: entryName, Method: zip.Deflate} + header.SetModTime(message.date) + entry, err := zw.CreateHeader(header) if err != nil { _ = zw.Close() return } - if _, err := entry.Write(raw); err != nil { + if _, err := entry.Write(message.raw); err != nil { _ = zw.Close() return } @@ -74,6 +83,10 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) { mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) where := []string{} args := []any{} + selectedIDs, err := selectedExportMessageIDs(r) + if err != nil { + return nil, err + } if view == "unknown" { if user.Role != "admin" { @@ -115,6 +128,14 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) { return nil, errors.New("unsupported mail view") } } + if len(selectedIDs) > 0 { + placeholders := make([]string, 0, len(selectedIDs)) + for _, id := range selectedIDs { + placeholders = append(placeholders, "?") + args = append(args, id) + } + where = append(where, "m.id IN ("+strings.Join(placeholders, ",")+")") + } rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...) if err != nil { @@ -132,40 +153,77 @@ func (a *App) exportMessageIDs(r *http.Request) ([]string, error) { return ids, rows.Err() } -func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) { +func selectedExportMessageIDs(r *http.Request) ([]string, error) { + values := r.URL.Query()["messageId"] + if len(values) == 0 { + return nil, nil + } + seen := make(map[string]struct{}, len(values)) + ids := make([]string, 0, len(values)) + for _, value := range values { + id := strings.TrimSpace(value) + if id == "" { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + if len(ids) > maxSelectedMailExport { + return nil, fmt.Errorf("最多一次下载 %d 封邮件", maxSelectedMailExport) + } + } + return ids, nil +} + +func (a *App) rawMessageForExport(ctx context.Context, id string) (exportedMessage, error) { msg, err := a.storedMessageByID(ctx, id) if err != nil { - return nil, "", err + return exportedMessage{}, err + } + exportDate := msg.ReceivedAt + if exportDate.IsZero() { + exportDate = messageDate(msg) } if msg.RawPath != "" { if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok { if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil { - return raw, msg.Subject, nil + return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, nil } } } attachments, err := a.attachmentInputsForMessage(ctx, id) if err != nil { - return nil, "", err + return exportedMessage{}, err } raw, err := BuildMIME(MIMEMessage{ From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC, Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID, Date: messageDate(msg), Attachments: attachments, }) - return raw, msg.Subject, err + return exportedMessage{raw: raw, subject: msg.Subject, date: exportDate}, err } -func exportMessageFilename(subject, id string, index int) string { - name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-") - name = strings.Trim(name, ".-_") +func exportMessageFilename(subject string, date time.Time) string { + name := strings.Map(func(r rune) rune { + if unicode.IsControl(r) || strings.ContainsRune(`<>:"/\\|?*`, r) { + return '-' + } + return r + }, strings.TrimSpace(subject)) + name = strings.Trim(name, " .-_") if name == "" { - name = "message" + name = "无主题" } - if len(name) > 80 { - name = name[:80] + runes := []rune(name) + if len(runes) > 80 { + name = string(runes[:80]) } - return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id) + if date.IsZero() { + return name + ".eml" + } + return fmt.Sprintf("%s (%s).eml", name, date.Format("20060102")) } func uniqueExportFilename(name string, used map[string]int) string { diff --git a/apps/api/internal/app/mail_transfer_handlers_test.go b/apps/api/internal/app/mail_transfer_handlers_test.go index 6d915eb..d74a1d4 100644 --- a/apps/api/internal/app/mail_transfer_handlers_test.go +++ b/apps/api/internal/app/mail_transfer_handlers_test.go @@ -5,11 +5,15 @@ import ( "bytes" "encoding/json" "io" + "mime" "mime/multipart" "net/http" "net/http/httptest" + stdmail "net/mail" + "net/url" "strings" "testing" + "time" ) func TestParseMBOXMultipleMessages(t *testing.T) { @@ -65,7 +69,7 @@ func TestMailImportExportAndOwnership(t *testing.T) { t.Fatalf("owner login=%d", code) } - 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: \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: 中文标题\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: \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: \r\nContent-Type: text/plain; charset=utf-8\r\n\r\nolder import") var imported struct { Imported int `json:"imported"` @@ -79,9 +83,13 @@ func TestMailImportExportAndOwnership(t *testing.T) { var list struct { 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) != 2 || list.Items[0].Subject != "imported message" || list.Items[1].Subject != "older 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 != "中文标题" || list.Items[1].Subject != "older imported message" { t.Fatalf("list code=%d items=%+v", code, list.Items) } + receivedAt := time.Date(2024, time.January, 3, 8, 30, 0, 0, time.UTC) + if _, err := a.db.Exec(`UPDATE messages SET received_at=? WHERE id=?`, receivedAt.Format(time.RFC3339Nano), list.Items[0].ID); err != nil { + t.Fatal(err) + } status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID) if status != http.StatusOK { @@ -94,15 +102,47 @@ func TestMailImportExportAndOwnership(t *testing.T) { if len(zr.File) != 2 { t.Fatalf("zip entries=%d", len(zr.File)) } + if zr.File[0].Name != "中文标题 (20240103).eml" { + t.Fatalf("first filename=%q", zr.File[0].Name) + } + wantModified := receivedAt + if !zr.File[0].Modified.Equal(wantModified) { + t.Fatalf("first modified=%s want=%s", zr.File[0].Modified, wantModified) + } entry, err := zr.File[0].Open() if err != nil { t.Fatal(err) } exported, err := io.ReadAll(entry) entry.Close() - if err != nil || !bytes.Contains(exported, []byte("Subject: imported message")) { + if err != nil { t.Fatalf("exported message err=%v raw=%q", err, exported) } + parsed, err := stdmail.ReadMessage(bytes.NewReader(exported)) + if err != nil { + t.Fatal(err) + } + decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject")) + if err != nil || decodedSubject != "中文标题" { + t.Fatalf("decoded subject=%q err=%v", decodedSubject, err) + } + messageDate, err := parsed.Header.Date() + if err != nil || !messageDate.Equal(time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC)) { + t.Fatalf("message date=%s err=%v", messageDate, err) + } + + selectedPath := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(list.Items[1].ID) + status, selectedArchive := getMailExport(t, owner, selectedPath) + if status != http.StatusOK { + t.Fatalf("selected export status=%d body=%q", status, selectedArchive) + } + selectedZip, err := zip.NewReader(bytes.NewReader(selectedArchive), int64(len(selectedArchive))) + if err != nil { + t.Fatal(err) + } + if len(selectedZip.File) != 1 || selectedZip.File[0].Name != "older imported message (20240101).eml" { + t.Fatalf("selected entries=%v", exportEntryNames(selectedZip.File)) + } var denied map[string]any if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound { @@ -114,6 +154,64 @@ func TestMailImportExportAndOwnership(t *testing.T) { } } +func TestSelectedMailExportStillEnforcesOwnership(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("admin login=%d", code) + } + var domains struct { + Items []Domain `json:"items"` + } + if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 { + t.Fatalf("domains code=%d items=%d", code, len(domains.Items)) + } + ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-owner", "Export Owner", "Password123!", nil) + otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "export-other", "Export Other", "Password123!", nil) + owner := &testClient{t: t, server: ts} + other := &testClient{t: t, server: ts} + if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("owner login=%d", code) + } + if code := other.do("POST", "/api/auth/login", map[string]string{"email": otherMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("other login=%d", code) + } + otherEML := []byte("From: sender@example.com\r\nTo: " + otherMailbox.Address + "\r\nSubject: private message\r\nDate: Tue, 2 Jan 2024 12:00:00 +0000\r\nMessage-ID: \r\n\r\nprivate") + var imported map[string]any + if code := doMailImport(t, other, otherMailbox.ID, "Inbox", map[string][]byte{"private.eml": otherEML}, &imported); code != http.StatusOK { + t.Fatalf("other import=%d response=%v", code, imported) + } + var otherList struct { + Items []MailMessage `json:"items"` + } + if code := other.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+otherMailbox.ID, nil, &otherList); code != http.StatusOK || len(otherList.Items) != 1 { + t.Fatalf("other list code=%d items=%d", code, len(otherList.Items)) + } + path := "/api/mail/export?view=folder&folder=Inbox&mailboxId=" + ownerMailbox.ID + "&messageId=" + url.QueryEscape(otherList.Items[0].ID) + status, archive := getMailExport(t, owner, path) + if status != http.StatusOK { + t.Fatalf("cross-owner export status=%d body=%q", status, archive) + } + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + t.Fatal(err) + } + if len(zr.File) != 0 { + t.Fatalf("cross-owner export leaked entries=%v", exportEntryNames(zr.File)) + } +} + +func exportEntryNames(files []*zip.File) []string { + names := make([]string, 0, len(files)) + for _, file := range files { + names = append(names, file.Name) + } + return names +} + func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int { t.Helper() var body bytes.Buffer diff --git a/apps/api/internal/app/mailbox_delete_test.go b/apps/api/internal/app/mailbox_delete_test.go new file mode 100644 index 0000000..ee8d40e --- /dev/null +++ b/apps/api/internal/app/mailbox_delete_test.go @@ -0,0 +1,34 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("admin login=%d", code) + } + var mailboxes struct { + Items []Mailbox `json:"items"` + } + if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 { + t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items)) + } + if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK { + t.Fatalf("delete final mailbox code=%d", code) + } + if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 { + t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items)) + } + var me map[string]any + if code := admin.do("GET", "/api/me", nil, &me); code != http.StatusOK { + t.Fatalf("account was not preserved code=%d", code) + } +} diff --git a/apps/api/internal/app/open_api_handlers.go b/apps/api/internal/app/open_api_handlers.go index 43f33c7..670d97c 100644 --- a/apps/api/internal/app/open_api_handlers.go +++ b/apps/api/internal/app/open_api_handlers.go @@ -318,26 +318,9 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - var owner string - if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil { - respondError(w, http.StatusNotFound, "mailbox not found") - return - } - current := currentUser(r) - if current != nil && owner == current.ID { - var count int - if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil { - respondError(w, http.StatusInternalServerError, "failed to check mailbox") - return - } - if count <= 1 { - badRequest(w, errors.New("cannot delete your last mailbox")) - return - } - } rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to load mailbox messages") + respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败") return } messageIDs := []string{} @@ -353,11 +336,11 @@ func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) } res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to delete mailbox") + respondError(w, http.StatusInternalServerError, "删除邮箱失败") return } if affected, _ := res.RowsAffected(); affected == 0 { - respondError(w, http.StatusNotFound, "mailbox not found") + respondError(w, http.StatusNotFound, "邮箱不存在或已被删除") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true}) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 5c25d79..80d8e8c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -251,11 +251,12 @@ export const api = { if (mailboxId) params.set("mailboxId", mailboxId) return request>(`/api/mail/starred?${params.toString()}`) }, - exportMailUrl: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => { + exportMailUrl: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string; messageIds?: string[] }) => { const query = new URLSearchParams({ view: params.view }) if (params.mailboxId) query.set("mailboxId", params.mailboxId) if (params.folder) query.set("folder", params.folder) if (params.labelId) query.set("labelId", params.labelId) + params.messageIds?.forEach((id) => query.append("messageId", id)) return `/api/mail/export?${query.toString()}` }, importMail: (files: File[], payload: { mailboxId: string; folder: string }) => { diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index e34905f..3fb1ab5 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -194,11 +194,7 @@ export function MailPage() { React.useEffect(() => { if (!user) return - const timer = window.setTimeout(() => { - void import("@/pages/profile") - if (user.role === "admin") void import("@/pages/admin") - }, 400) - return () => window.clearTimeout(timer) + void import("@/pages/profile") }, [user]) const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) @@ -1109,18 +1105,20 @@ export function MailPage() { if (!canExportCurrentView || exportingMail) return setExportingMail(true) const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder" + const selectedMessageIds = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)) const anchor = document.createElement("a") anchor.href = api.exportMailUrl({ view: exportView, mailboxId: mailView === "unknown" ? undefined : activeMailboxId, folder: exportView === "folder" ? folder : undefined, labelId: exportView === "label" ? selectedLabelId : undefined, + messageIds: selectedMessageIds.length > 0 ? selectedMessageIds : undefined, }) anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip` document.body.appendChild(anchor) anchor.click() anchor.remove() - toast({ title: "已开始下载", description: "邮件将打包为 ZIP,压缩包内为标准 EML 文件;邮件较多时请查看浏览器下载进度。" }) + toast({ title: "已开始下载", description: selectedMessageIds.length > 0 ? `正在打包选中的 ${selectedMessageIds.length} 封邮件。` : "邮件将打包为 ZIP,压缩包内为标准 EML 文件;邮件较多时请查看浏览器下载进度。" }) window.setTimeout(() => setExportingMail(false), 1000) } function chooseMailImport() { @@ -1168,10 +1166,7 @@ export function MailPage() { toast({ title: "邮箱地址已复制" }) } function openSettings() { - navigate("/profile") - } - function openAdmin() { - navigate("/admin") + void import("@/pages/profile").then(() => navigate("/profile")) } function toggleAdvancedSearch() { setAdvancedSearchDraft(advancedSearch) @@ -1223,7 +1218,6 @@ export function MailPage() { language={language} onLanguageChange={setLanguage} onSettings={openSettings} - onAdmin={user?.role === "admin" ? openAdmin : undefined} />
- {mailView !== "unknown" && ( @@ -3199,7 +3193,7 @@ function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange ) } -function AccountHeader({ collapsed, name, email, darkMode, language, onToggleTheme, onLanguageChange, onSettings, onAdmin }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; language: Language; onToggleTheme: () => void; onLanguageChange: (language: Language) => void; onSettings: () => void; onAdmin?: () => void }) { +function AccountHeader({ collapsed, name, email, darkMode, language, onToggleTheme, onLanguageChange, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; language: Language; onToggleTheme: () => void; onLanguageChange: (language: Language) => void; onSettings: () => void }) { const displayName = cleanAccountName(name, email) const currentLanguage = languageOptions.find((item) => item.value === language) || languageOptions[0] if (collapsed) { @@ -3222,10 +3216,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
- {onAdmin && } - @@ -3243,7 +3234,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe ))} -