diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 661ed55..bc8dd9b 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -24,6 +24,10 @@ const mailMessagesPageSize = 30 const customFolderDefaultSortOrderBase = 100000 +func isAllMailboxID(mailboxID string) bool { + return strings.EqualFold(strings.TrimSpace(mailboxID), "all") +} + type AttachmentInput struct { Filename string `json:"filename"` ContentType string `json:"contentType"` @@ -81,6 +85,10 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) { } func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { + if isAllMailboxID(r.URL.Query().Get("mailboxId")) { + a.handleAllMailFolders(w, r) + return + } mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") @@ -118,6 +126,43 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role, + COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, + COUNT(m.id) AS total, + MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq) + FROM folders f + JOIN mailboxes mb ON mb.id=f.mailbox_id + LEFT JOIN messages m ON m.folder_id=f.id + WHERE mb.user_id=? AND mb.status='active' + GROUP BY f.name,f.role + ORDER BY CASE + WHEN lower(f.name)='inbox' THEN 1000 + WHEN lower(f.name)='sent' THEN 5000 + WHEN lower(f.name)='drafts' THEN 6000 + WHEN lower(f.name)='archive' THEN 7000 + WHEN lower(f.name)='spam' THEN 8000 + WHEN lower(f.name)='trash' THEN 9000 + ELSE MIN(f.sort_order) + END, f.name`, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load folders") + return + } + defer rows.Close() + items := []MailFolder{} + for rows.Next() { + var f MailFolder + if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan folders") + return + } + items = append(items, f) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { @@ -345,6 +390,29 @@ func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) ( } func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) { + if isAllMailboxID(r.URL.Query().Get("mailboxId")) { + user := currentUser(r) + if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" { + if !a.labelBelongsToUser(r.Context(), labelID, user.ID) { + respondError(w, http.StatusNotFound, "label not found") + return + } + a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID}) + return + } + folder := r.URL.Query().Get("folder") + if folder == "" { + folder = "Inbox" + } + if normalized, err := normalizeFolderNameForUser(folder); err != nil { + badRequest(w, err) + return + } else { + folder = normalized + } + a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND f.name=?`, []any{user.ID, folder}) + return + } mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") @@ -377,6 +445,11 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) { } func (a *App) handleStarredMessages(w http.ResponseWriter, r *http.Request) { + if isAllMailboxID(r.URL.Query().Get("mailboxId")) { + user := currentUser(r) + a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND m.is_starred=1`, []any{user.ID}) + return + } mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") @@ -394,9 +467,15 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe limit := mailMessagesPageSize if q != "" { - where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)` + where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)` like := "%" + q + "%" - args = append(args, like, like, like, like, like) + args = append(args, like, like, like, like, like, like, like, like) + } + var err error + where, args, err = appendMailMessageSearchFilters(r, where, args) + if err != nil { + badRequest(w, err) + return } args = append(args, limit+1, offset) query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes @@ -428,7 +507,131 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next}) } +func appendMailMessageSearchFilters(r *http.Request, where string, args []any) (string, []any, error) { + if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" { + where += ` AND (m.from_addr LIKE ? OR m.from_name LIKE ?)` + like := "%" + from + "%" + args = append(args, like, like) + } + if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" { + where += ` AND (m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.bcc_addrs LIKE ? OR m.recipient_addr LIKE ?)` + like := "%" + to + "%" + args = append(args, like, like, like, like) + } + if subject := strings.TrimSpace(r.URL.Query().Get("subject")); subject != "" { + where += ` AND m.subject LIKE ?` + args = append(args, "%"+subject+"%") + } + switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("attachmentMode"))) { + case "with": + where += ` AND m.has_attachments=1` + case "without": + where += ` AND m.has_attachments=0` + default: + if mailSearchFlag(r, "hasAttachments") { + where += ` AND m.has_attachments=1` + } + } + if minSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("minSizeKb"), "minSizeKb"); err != nil { + return where, args, err + } else if ok { + where += ` AND m.size_bytes>=?` + args = append(args, minSize) + } + if maxSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("maxSizeKb"), "maxSizeKb"); err != nil { + return where, args, err + } else if ok { + where += ` AND m.size_bytes<=?` + args = append(args, maxSize) + } + switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("readStatus"))) { + case "read": + where += ` AND m.is_read=1` + case "unread": + where += ` AND m.is_read=0` + default: + if mailSearchFlag(r, "unread") { + where += ` AND m.is_read=0` + } + } + switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("flagStatus"))) { + case "starred": + where += ` AND m.is_starred=1` + case "unstarred": + where += ` AND m.is_starred=0` + default: + if mailSearchFlag(r, "starred") { + where += ` AND m.is_starred=1` + } + } + if start, ok, err := mailSearchDateBoundary(r.URL.Query().Get("startDate"), false); err != nil { + return where, args, err + } else if ok { + where += ` AND m.received_at>=?` + args = append(args, start) + } + if end, ok, err := mailSearchDateBoundary(r.URL.Query().Get("endDate"), true); err != nil { + return where, args, err + } else if ok { + where += ` AND m.received_at<=?` + args = append(args, end) + } + return where, args, nil +} + +func mailSearchFlag(r *http.Request, key string) bool { + switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func mailSearchDateBoundary(value string, endOfDay bool) (string, bool, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", false, nil + } + if len(value) == len("2006-01-02") { + t, err := time.Parse("2006-01-02", value) + if err != nil { + return "", false, fmt.Errorf("invalid date %q", value) + } + if endOfDay { + t = t.AddDate(0, 0, 1).Add(-time.Nanosecond) + } + return t.UTC().Format(time.RFC3339Nano), true, nil + } + t, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return "", false, fmt.Errorf("invalid date %q", value) + } + return t.UTC().Format(time.RFC3339Nano), true, nil +} + +func mailSearchSizeBytes(value string, key string) (int64, bool, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false, nil + } + kb, err := strconv.ParseInt(value, 10, 64) + if err != nil || kb < 0 { + return 0, false, fmt.Errorf("invalid %s %q", key, value) + } + return kb * 1024, true, nil +} + func (a *App) handleMailLabels(w http.ResponseWriter, r *http.Request) { + if isAllMailboxID(r.URL.Query().Get("mailboxId")) { + labels, err := a.labelsForUser(r.Context(), currentUser(r).ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load labels") + return + } + respondJSON(w, http.StatusOK, map[string]any{"items": labels}) + return + } mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") @@ -1121,15 +1324,23 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) { func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) { user := currentUser(r) - mb, err := a.mailboxForCurrentUser(r) - if err != nil { - respondError(w, http.StatusNotFound, "mailbox not found") - return + mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) + args := []any{user.ID} + where := `user_id=?` + if !isAllMailboxID(mailboxID) { + mb, err := a.mailboxForCurrentUser(r) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + where += ` AND mailbox_id=?` + args = append(args, mb.ID) } + args = append(args, "pending", "sending", "failed") rows, err := a.db.QueryContext(r.Context(), `SELECT id,mailbox_id,draft_id,payload_json,send_at,status,error,created_at,updated_at,sent_at FROM scheduled_sends - WHERE user_id=? AND mailbox_id=? AND status IN ('pending','sending','failed') - ORDER BY send_at ASC, created_at DESC`, user.ID, mb.ID) + WHERE `+where+` AND status IN (?,?,?) + ORDER BY send_at ASC, created_at DESC`, args...) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load scheduled sends") return @@ -1166,20 +1377,28 @@ func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) { func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) { user := currentUser(r) - mb, err := a.mailboxForCurrentUser(r) - if err != nil { - respondError(w, http.StatusNotFound, "mailbox not found") - return - } + mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) status := strings.TrimSpace(r.URL.Query().Get("status")) + if strings.EqualFold(status, "all") { + status = "" + } cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor")) if err != nil { badRequest(w, err) return } limit := 30 - args := []any{user.ID, mb.ID} - where := `mb.user_id=? AND sq.mailbox_id=?` + args := []any{user.ID} + where := `mb.user_id=?` + if !isAllMailboxID(mailboxID) { + mb, err := a.mailboxForCurrentUser(r) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + where += ` AND sq.mailbox_id=?` + args = append(args, mb.ID) + } if status != "" { if !validSendQueueStatus(status) { badRequest(w, errors.New("invalid send queue status")) @@ -2169,6 +2388,29 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab return items, rows.Err() } +func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) { + rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id) + FROM mail_labels l + JOIN mailboxes mb ON mb.id=l.mailbox_id + LEFT JOIN message_labels ml ON ml.label_id=l.id + WHERE mb.user_id=? AND mb.status='active' + GROUP BY l.id,l.mailbox_id,l.name,l.color + ORDER BY lower(l.name)`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []MailLabel{} + for rows.Next() { + var item MailLabel + if err := rows.Scan(&item.ID, &item.MailboxID, &item.Name, &item.Color, &item.MessageCount); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLabel, error) { rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id @@ -2259,6 +2501,12 @@ func (a *App) labelBelongsToMailbox(ctx context.Context, labelID, mailboxID stri return count > 0 } +func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bool { + var count int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=?`, labelID, userID).Scan(&count) + return count > 0 +} + func normalizeLabelName(name string) string { name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ") if len([]rune(name)) > 32 { diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index 266e3d5..2094446 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -623,7 +623,7 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) { mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) args := []any{user.ID} where := `mb.user_id=?` - if mailboxID != "" { + if mailboxID != "" && !isAllMailboxID(mailboxID) { if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return @@ -642,7 +642,7 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to load attachment stats") return } - if mailboxID != "" { + if mailboxID != "" && !isAllMailboxID(mailboxID) { var quotaMB int64 if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil { respondError(w, http.StatusInternalServerError, "failed to load quota") diff --git a/apps/web/index.html b/apps/web/index.html index be38529..e38ccd8 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,7 +3,7 @@ - LanQin Email + NodeSeek 邮箱
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 92b1fa1..cde243d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -5,39 +5,46 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 240 10% 3.9%; + --foreground: 222 47% 11%; --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; + --card-foreground: 222 47% 11%; --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; + --popover-foreground: 222 47% 11%; + --primary: 224 44% 12%; --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; + --secondary: 213 37% 96%; + --secondary-foreground: 222 47% 11%; + --muted: 213 37% 96%; + --muted-foreground: 216 22% 42%; + --accent: 213 37% 94%; + --accent-foreground: 222 47% 11%; + --destructive: 358 88% 61%; --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 5.9% 10%; + --border: 214 32% 90%; + --input: 214 32% 86%; + --ring: 216 22% 42%; --radius: 0.5rem; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; + --sidebar-background: 0 0% 100%; + --sidebar-foreground: 222 47% 11%; + --sidebar-primary: 224 44% 12%; --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + --sidebar-accent: 213 37% 94%; + --sidebar-accent-foreground: 222 47% 11%; + --sidebar-border: 214 32% 90%; + --sidebar-ring: 216 22% 42%; } * { @apply border-border; } - body { @apply bg-background text-foreground antialiased; } + html { + color-scheme: light; + font-size: 15px; + } + body { + @apply bg-background text-foreground antialiased; + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + font-size: 13px; + } html, body, #root { min-height: 100%; } - html { color-scheme: light; } html.dark { color-scheme: dark; } .dark { @@ -71,6 +78,38 @@ } } +@layer components { + [data-sidebar="content"] { + gap: 0.75rem; + } + + [data-sidebar="group"] { + padding: 0 0.25rem; + } + + [data-sidebar="menu"] { + gap: 0.25rem; + } + + [data-sidebar="menu-button"] { + color: hsl(var(--sidebar-foreground)); + } + + [data-sidebar="menu-button"] svg { + color: hsl(var(--muted-foreground)); + stroke-width: 1.8; + } + + [data-sidebar="menu-button"][data-active="true"] { + background: hsl(var(--sidebar-accent)); + color: hsl(var(--sidebar-accent-foreground)); + } + + [data-sidebar="menu-button"][data-active="true"] svg { + color: hsl(var(--muted-foreground)); + } +} + html.theme-transition body { transition: background-color 180ms ease, color 180ms ease; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0aaaa01..63014bc 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -4,6 +4,44 @@ export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 const MAIL_DELIVERY_TIMEOUT_MS = 60_000 +export type MailSearchParams = { + q?: string + from?: string + to?: string + subject?: string + startDate?: string + endDate?: string + attachmentMode?: "all" | "with" | "without" + minSizeKb?: string + maxSizeKb?: string + readStatus?: "all" | "read" | "unread" + flagStatus?: "all" | "starred" | "unstarred" + hasAttachments?: boolean + unread?: boolean + starred?: boolean +} + +function appendMailSearchParams(params: URLSearchParams, search: MailSearchParams | string) { + if (typeof search === "string") { + if (search) params.set("q", search) + return + } + if (search.q) params.set("q", search.q) + if (search.from) params.set("from", search.from) + if (search.to) params.set("to", search.to) + if (search.subject) params.set("subject", search.subject) + if (search.startDate) params.set("startDate", search.startDate) + if (search.endDate) params.set("endDate", search.endDate) + if (search.attachmentMode && search.attachmentMode !== "all") params.set("attachmentMode", search.attachmentMode) + else if (search.hasAttachments) params.set("hasAttachments", "1") + if (search.minSizeKb) params.set("minSizeKb", search.minSizeKb) + if (search.maxSizeKb) params.set("maxSizeKb", search.maxSizeKb) + if (search.readStatus && search.readStatus !== "all") params.set("readStatus", search.readStatus) + else if (search.unread) params.set("unread", "1") + if (search.flagStatus && search.flagStatus !== "all") params.set("flagStatus", search.flagStatus) + else if (search.starred) params.set("starred", "1") +} + async function request(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise { const { timeoutMs, ...requestInit } = init const controller = new AbortController() @@ -152,18 +190,21 @@ export const api = { return request(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) }) }, deleteLabel: (id: string, mailboxId?: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }), - messages: (folder: string, q = "", cursor = "", mailboxId?: string) => { - const params = new URLSearchParams({ folder, q, cursor }) + messages: (folder: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => { + const params = new URLSearchParams({ folder, cursor }) + appendMailSearchParams(params, search) if (mailboxId) params.set("mailboxId", mailboxId) return request>(`/api/mail/messages?${params.toString()}`) }, - labelMessages: (labelId: string, q = "", cursor = "", mailboxId?: string) => { - const params = new URLSearchParams({ labelId, q, cursor }) + labelMessages: (labelId: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => { + const params = new URLSearchParams({ labelId, cursor }) + appendMailSearchParams(params, search) if (mailboxId) params.set("mailboxId", mailboxId) return request>(`/api/mail/messages?${params.toString()}`) }, - starredMessages: (q = "", cursor = "", mailboxId?: string) => { - const params = new URLSearchParams({ q, cursor }) + starredMessages: (search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => { + const params = new URLSearchParams({ cursor }) + appendMailSearchParams(params, search) if (mailboxId) params.set("mailboxId", mailboxId) return request>(`/api/mail/starred?${params.toString()}`) }, @@ -199,5 +240,3 @@ export const api = { delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }), } - - diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index c9fec0e..f80f4cd 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -11,9 +11,8 @@ import TextAlign from "@tiptap/extension-text-align" import Placeholder from "@tiptap/extension-placeholder" import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style" import { useNavigate } from "react-router-dom" -import type { ImperativePanelHandle } from "react-resizable-panels" -import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" -import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api" +import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react" +import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api" import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" @@ -50,18 +49,18 @@ import { useIsMobile } from "@/hooks/use-mobile" import { useToast } from "@/hooks/use-toast" import { hasPermission } from "@/lib/permissions" -const folderIcons: Record = { inbox: , sent: , drafts: , archive: , spam: , trash: } +const folderIcons: Record = { inbox: , sent: , drafts: , archive: , spam: , trash: } const folderLabels: Record = { Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", - Archive: "归档", + Archive: "已归档", Spam: "垃圾邮件", - Trash: "回收站", + Trash: "已删除", } type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean } -type MailFilter = "all" | "unread" | "starred" | "attachments" +type MailFilter = "all" | "unread" | "starred" | "attachments" | "recent7" type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" | "external" type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } @@ -70,6 +69,9 @@ type ComposeSendIntent = { title: string; description: string; confirmText: stri type MessageContextMenuState = { message: MailMessage; x: number; y: number } type SidebarContextMenuState = { item: MailMenuItem; x: number; y: number } type FolderDropTarget = { key: string; edge: "before" | "after" | "end" } +type AdvancedMailSearch = { from: string; to: string; subject: string; startDate: string; endDate: string; hasAttachments: boolean; unread: boolean; starred: boolean } +type AdvancedMailSearchDraft = AdvancedMailSearch +type AdvancedSearchChip = { key: keyof AdvancedMailSearch; label: string } type MailMenuItem = | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number; order: number } | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number; order: number } @@ -77,12 +79,17 @@ type MailMenuItem = | { type: "folder"; key: string; folderId: string; folderName: string; label: string; icon: React.ReactNode; count: number; custom: boolean; order: number } const filterLabels: Record = { - all: "全部邮件", - unread: "未读邮件", - starred: "星标邮件", + all: "全部", + unread: "未读", + starred: "已加旗标", attachments: "有附件", + recent7: "最近 7 天", } +const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false } +const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch } +const mailboxSelectionStorageVersion = "2" + export function MailPage() { const qc = useQueryClient() const { toast } = useToast() @@ -92,13 +99,19 @@ export function MailPage() { const [mailView, setMailView] = React.useState("folder") const [selectedLabelId, setSelectedLabelId] = React.useState("") const [query, setQuery] = React.useState("") + const [advancedSearchOpen, setAdvancedSearchOpen] = React.useState(false) + const [advancedSearch, setAdvancedSearch] = React.useState(emptyAdvancedSearch) + const [advancedSearchDraft, setAdvancedSearchDraft] = React.useState(emptyAdvancedSearchDraft) const [selectedId, setSelectedId] = React.useState(null) const [compactSelectedIds, setCompactSelectedIds] = React.useState([]) const [composeOpen, setComposeOpen] = React.useState(false) const [composeDraft, setComposeDraft] = React.useState() - const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false) + const sidebarCollapsed = false const [mailFilter, setMailFilter] = React.useState("all") - const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") + const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => { + if (localStorage.getItem("lanqin:selected-mailbox-version") !== mailboxSelectionStorageVersion) return "all" + return localStorage.getItem("lanqin:selected-mailbox") || "all" + }) const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("") const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState([]) const [externalFolder, setExternalFolder] = React.useState("INBOX") @@ -127,7 +140,6 @@ export function MailPage() { const [folderDialogOpen, setFolderDialogOpen] = React.useState(false) const [draggingFolderId, setDraggingFolderId] = React.useState("") const [folderDropTarget, setFolderDropTarget] = React.useState(null) - const sidebarPanelRef = React.useRef(null) const themeMountedRef = React.useRef(false) const mailNotifyStateRef = React.useRef>({}) const mailAudioContextRef = React.useRef(null) @@ -148,8 +160,14 @@ export function MailPage() { const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled }) const selectedExternalAccount = React.useMemo(() => externalImapEnabled ? externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId) : undefined, [externalImapEnabled, externalMailAccounts.data?.items, selectedExternalAccountId]) const externalFolders = useQuery({ queryKey: ["mail-external-folders", selectedExternalAccountId], queryFn: () => api.externalFolders(selectedExternalAccountId), enabled: !!selectedExternalAccountId && canReadMail && externalImapEnabled }) - const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) - const activeMailboxId = selectedMailbox?.id || "" + const selectedMailbox = React.useMemo(() => { + const items = mailboxList.data?.items || [] + if (selectedMailboxId === "all") return undefined + return items.find((item) => item.id === selectedMailboxId) || items[0] + }, [mailboxList.data?.items, selectedMailboxId]) + const isAllMailboxSelected = selectedMailboxId === "all" + const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || "" + const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined) const hasMailboxes = (mailboxList.data?.items.length || 0) > 0 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) }) @@ -164,6 +182,12 @@ export function MailPage() { }) const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue }) const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false + const advancedSearchActive = hasAdvancedMailSearch(advancedSearch) + const advancedSearchChips = React.useMemo(() => buildAdvancedSearchChips(advancedSearch), [advancedSearch]) + const mailSearchParams = React.useMemo(() => ({ + q: query.trim(), + ...advancedSearch, + }), [advancedSearch, query]) React.useEffect(() => { if (externalImapEnabled) return setSelectedExternalAccountId("") @@ -185,12 +209,12 @@ export function MailPage() { refetchIntervalInBackground: true, }) const messages = useInfiniteQuery({ - queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, query], + queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, mailSearchParams], queryFn: ({ pageParam }) => { const cursor = typeof pageParam === "string" ? pageParam : "" - if (mailView === "starred") return api.starredMessages(query, cursor, activeMailboxId) - if (mailView === "label") return api.labelMessages(selectedLabelId, query, cursor, activeMailboxId) - return api.messages(folder, query, cursor, activeMailboxId) + if (mailView === "starred") return api.starredMessages(mailSearchParams, cursor, activeMailboxId) + if (mailView === "label") return api.labelMessages(selectedLabelId, mailSearchParams, cursor, activeMailboxId) + return api.messages(folder, mailSearchParams, cursor, activeMailboxId) }, initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, @@ -444,14 +468,15 @@ export function MailPage() { localStorage.removeItem("lanqin:selected-mailbox") return } - if (!selectedMailboxId || !items.some((item) => item.id === selectedMailboxId)) { - setSelectedMailboxId(items[0].id) + if (!selectedMailboxId || (selectedMailboxId !== "all" && !items.some((item) => item.id === selectedMailboxId))) { + setSelectedMailboxId("all") } }, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId]) React.useEffect(() => { if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId) else localStorage.removeItem("lanqin:selected-mailbox") + localStorage.setItem("lanqin:selected-mailbox-version", mailboxSelectionStorageVersion) }, [selectedMailboxId]) React.useEffect(() => { @@ -461,7 +486,7 @@ export function MailPage() { React.useEffect(() => { setCompactSelectedIds([]) - }, [selectedMailboxId, mailView, folder, selectedLabelId, query, displayMode]) + }, [selectedMailboxId, mailView, folder, selectedLabelId, query, advancedSearch, displayMode]) React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current) @@ -572,12 +597,18 @@ export function MailPage() { const selected = detail.data const allMessages = (mailView === "external" ? externalMessages.data?.pages : messages.data?.pages)?.flatMap((page) => page.items || []) || [] const visibleMessages = allMessages.filter((message) => { + if (!messageMatchesAdvancedSearch(message, advancedSearch)) return false if (mailFilter === "unread") return !message.isRead if (mailFilter === "starred") return message.isStarred if (mailFilter === "attachments") return message.hasAttachments + if (mailFilter === "recent7") { + const receivedAt = new Date(message.receivedAt).getTime() + return Number.isFinite(receivedAt) && receivedAt >= Date.now() - 7 * 24 * 60 * 60 * 1000 + } return true }) const unreadCount = allMessages.filter((message) => !message.isRead).length + const mailboxUnreadCount = (folders.data?.items || []).find((item) => item.name === "Inbox")?.unreadCount ?? unreadCount const starredCount = mailStats.data?.starredMessages ?? (mailView === "starred" ? allMessages.length : 0) const scheduledItems = scheduledSends.data?.items || [] const scheduledDraftIds = new Set(scheduledItems.map((item) => item.draftId).filter((draftId): draftId is string => Boolean(draftId))) @@ -590,6 +621,10 @@ export function MailPage() { const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length const visibleSendQueueItems = sendQueueItems const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue) + const primaryMailMenuItems = mailMenuItems.filter((item) => !isCustomMenuFolder(item)) + const customMailMenuItems = mailMenuItems.filter(isCustomMenuFolder) + const canOrganizeCurrentMailbox = canOrganizeMail && !isAllMailboxSelected + const canManageCurrentMailboxLabels = canManageLabels && !isAllMailboxSelected const externalAccountItems = externalImapEnabled ? externalMailAccounts.data?.items || [] : [] const externalFolderItems = externalImapEnabled ? externalFolders.data?.items || [] : [] const labelItems = labels.data?.items || [] @@ -719,6 +754,10 @@ export function MailPage() { setSelectedLabelId("") setSelectedId(null) setMailFilter("all") + if (mailboxId === "all") { + setLabelEditMode(false) + setNewLabelEditing(false) + } setMobileSidebarOpen(false) } function openFolder(nextFolder: string) { @@ -973,9 +1012,47 @@ export function MailPage() { function openSettings() { navigate("/profile") } + function toggleAdvancedSearch() { + setAdvancedSearchDraft(advancedSearch) + setAdvancedSearchOpen((open) => !open) + } + function updateAdvancedSearch(next: AdvancedMailSearch) { + setAdvancedSearch(next) + setAdvancedSearchDraft(next) + setSelectedId(null) + setCompactSelectedIds([]) + } + function applyAdvancedSearch(draft: AdvancedMailSearchDraft) { + updateAdvancedSearch({ + from: draft.from.trim(), + to: draft.to.trim(), + subject: draft.subject.trim(), + startDate: draft.startDate, + endDate: draft.endDate, + hasAttachments: draft.hasAttachments, + unread: draft.unread, + starred: draft.starred, + }) + setAdvancedSearchOpen(false) + } + function clearAdvancedSearch() { + updateAdvancedSearch(emptyAdvancedSearch) + } + function removeAdvancedSearchFilter(key: keyof AdvancedMailSearch) { + updateAdvancedSearch({ ...advancedSearch, [key]: emptyAdvancedSearch[key] }) + } + function handleSearchKeyDown(event: React.KeyboardEvent) { + if (event.key !== "Enter") return + const parsed = parseSmartSearchQuery(query) + if (!parsed.active) return + event.preventDefault() + setQuery(parsed.query) + updateAdvancedSearch({ ...advancedSearch, ...parsed.search }) + setAdvancedSearchOpen(false) + } const sidebarContent = ( - - + + - {!sidebarCollapsed && ( - )} {canSendMail && ( - )} - + {!sidebarCollapsed && ( -
- 邮件夹 - {canOrganizeMail && ( - - )} +
+ 邮件
)} - {mailMenuItems.map((item) => ( + {primaryMailMenuItems.map((item) => ( handleFolderDragStart(event, item)} onDragOver={(event) => handleFolderDragOver(event, item)} onDragLeave={() => { if (folderDropTarget?.key === item.key) setFolderDropTarget(null) }} @@ -1034,22 +1108,22 @@ export function MailPage() { activateSidebarItem(item)} - > - {item.icon} - {!sidebarCollapsed && {item.label}} - {!sidebarCollapsed && item.count > 0 && {item.count}} - - + > + {item.icon} + {!sidebarCollapsed && {item.label}} + + ))} - {!sidebarCollapsed && canOrganizeMail && ( + {!sidebarCollapsed && canOrganizeCurrentMailbox && customMailMenuItems.length === 0 && (
{ @@ -1113,18 +1187,77 @@ export function MailPage() { } - {(canReadMail || canManageLabels) && + {(customMailMenuItems.length > 0 || canOrganizeMail) && {!sidebarCollapsed && ( -
- 标签 - {canManageLabels && ( -
- - {labelItems.length > 0 && ( -
+ )} + + + {customMailMenuItems.map((item) => ( + handleFolderDragStart(event, item)} + onDragOver={(event) => handleFolderDragOver(event, item)} + onDragLeave={() => { if (folderDropTarget?.key === item.key) setFolderDropTarget(null) }} + onDrop={(event) => handleFolderDrop(event, item)} + onDragEnd={clearFolderDragState} + onContextMenu={(event) => openSidebarContextMenu(event, item)} + > + activateSidebarItem(item)} + > + {item.icon} + {!sidebarCollapsed && {item.label}} + {!sidebarCollapsed && item.count > 0 && {item.count}} + + + ))} + {!sidebarCollapsed && canOrganizeCurrentMailbox && ( +
{ + if (!draggingFolderId) return + event.preventDefault() + event.dataTransfer.dropEffect = "move" + setFolderDropTarget({ key: "__end__", edge: "end" }) + }} + onDragLeave={() => { if (folderDropTarget?.edge === "end") setFolderDropTarget(null) }} + onDrop={handleFolderDropEnd} + /> + )} + + + } + {(canReadMail || canManageLabels) && + {!sidebarCollapsed && ( +
+ 标签 + {canManageCurrentMailboxLabels && ( +
+ + {labelEditMode && ( + )}
@@ -1134,26 +1267,26 @@ export function MailPage() { {canReadMail && labelItems.map((label) => { - const colors = generateLabelColor(label.name) + const dotColor = labelDotColor(label) return ( { if (!labelEditMode) openLabel(label.id) }} > {sidebarCollapsed ? ( - + ) : ( - - - {label.name} - + + + {label.name} + )} {!sidebarCollapsed && !labelEditMode && !!label.messageCount && ( {label.messageCount} )} - {!sidebarCollapsed && labelEditMode && canManageLabels && ( + {!sidebarCollapsed && labelEditMode && canManageCurrentMailboxLabels && (
} - {canManageLabels && labelEditMode && newLabelEditing && ( + {canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && ( { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} /> @@ -1179,25 +1312,8 @@ export function MailPage() {
} - {!isMobile && ( -
- -
- )} ) - function toggleSidebar() { - if (sidebarCollapsed) { - sidebarPanelRef.current?.expand(14) - setSidebarCollapsed(false) - } else { - sidebarPanelRef.current?.collapse() - setSidebarCollapsed(true) - } - } const contentView = !canAccessMail ? ( @@ -1285,23 +1401,79 @@ export function MailPage() { language={language} /> ) : ( - - -
-
+ + +
+
+
+ + setQuery(e.target.value)} onKeyDown={handleSearchKeyDown} placeholder="搜索发件人、主题、内容..." className="h-10 rounded-lg bg-background pl-9 text-sm shadow-none" /> +
+
+ + {advancedSearchOpen && ( + + )} +
+ {advancedSearchChips.length > 0 && ( +
+ {advancedSearchChips.map((chip) => ( + removeAdvancedSearchFilter(chip.key)} /> + ))} + +
+ )} +
+ 快捷筛选 + {(["attachments", "starred", "recent7"] as MailFilter[]).map((value) => ( + + ))} +
+
+
- toggleCompactSelectAll(value === true)} /> -
-
{mailView === "label" && selectedLabel && {selectedLabel.name}}{mailView !== "label" && viewTitle}
-
{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}
+
+ toggleCompactSelectAll(value === true)} /> + +
+
+

{mailView === "label" && selectedLabel ? selectedLabel.name : viewTitle}

+
+ + {canOrganizeMail && } + +
- {selectedCountOnPage > 0 && canOrganizeMail && } +
+ {selectedCountOnPage > 0 && canOrganizeMail && } + + +
{(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && } {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)} - {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && visibleMessages.length === 0 &&
{emptyMessage}
} + {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && visibleMessages.length === 0 &&
{emptyMessage}
} {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && hasMoreMessages && (
- + - -
- {!selectedId &&
选择一封邮件阅读
} + +
+ {!selectedId && ( +
+
+ +
选择一封邮件以查看详情
+
+
+ )} {detail.isLoading &&
} {selected &&
@@ -1371,7 +1550,7 @@ export function MailPage() {
{mailView === "label" && selectedLabel ? {selectedLabel.name} : viewTitle}
- {canSendMail && } + {canSendMail && }
setQuery(e.target.value)} placeholder={mailView === "external" ? "搜索远端邮件" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" /> @@ -1382,45 +1561,12 @@ export function MailPage() {
) : ( - setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}> + {sidebarContent} - - + +
-
-
- - {(publicSettings.data?.mailAutoRefresh || autoRefreshing) && ( -
- {autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"} -
- )} - {mailView !== "scheduled" && mailView !== "sendQueue" && mailView !== "external" && ( - <> - {canOrganizeMail && } - - - - - - {(Object.keys(filterLabels) as MailFilter[]).map((value) => ( - setMailFilter(value)}> - {filterLabels[value]} - - ))} - - - - )} -
-
- - setQuery(e.target.value)} placeholder={mailView === "external" ? "搜索远端邮件" : mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" /> -
-
{contentView}
@@ -1428,7 +1574,7 @@ export function MailPage() { )} - { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} /> + { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} /> , + icon: isCustomMailFolder(item) ? : folderIcons[item.role] || , count: item.name === "Drafts" ? item.totalCount : item.unreadCount, custom: isCustomMailFolder(item), order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name), })) const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount, order: 2000 } - const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: , count: scheduledCount, order: 3000 } - const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: , count: sendQueueCount, order: 4000 } + const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "稍后提醒", icon: , count: scheduledCount, order: 6000 } + const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: , count: sendQueueCount, order: 9000 } const specialItems: MailMenuItem[] = [starredItem] if (includeScheduled) specialItems.push(scheduledItem) - if (includeSendQueue) specialItems.push(sendQueueItem) + if (includeSendQueue && sendQueueCount > 0) specialItems.push(sendQueueItem) return [...folderItems, ...specialItems].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)) } @@ -1570,18 +1716,170 @@ function isCustomMenuFolder(item: MailMenuItem): item is Extract end)) return false + } + return true +} + +function buildAdvancedSearchChips(search: AdvancedMailSearch): AdvancedSearchChip[] { + const chips: AdvancedSearchChip[] = [] + if (search.from.trim()) chips.push({ key: "from", label: `发件人: ${search.from.trim()}` }) + if (search.to.trim()) chips.push({ key: "to", label: `收件人: ${search.to.trim()}` }) + if (search.subject.trim()) chips.push({ key: "subject", label: `主题: ${search.subject.trim()}` }) + if (search.startDate) chips.push({ key: "startDate", label: `开始: ${search.startDate}` }) + if (search.endDate) chips.push({ key: "endDate", label: `结束: ${search.endDate}` }) + if (search.hasAttachments) chips.push({ key: "hasAttachments", label: "有附件" }) + if (search.unread) chips.push({ key: "unread", label: "未读" }) + if (search.starred) chips.push({ key: "starred", label: "星标" }) + return chips +} + +function parseSmartSearchQuery(raw: string): { active: boolean; query: string; search: Partial } { + const search: Partial = {} + let active = false + const query = raw.replace(/\b(from|to|subject|after|before|has|is):(?:"([^"]*)"|'([^']*)'|(\S+))/gi, (match, key: string, quoted: string, singleQuoted: string, bare: string) => { + const value = (quoted || singleQuoted || bare || "").trim() + const normalizedKey = key.toLowerCase() + const normalizedValue = value.toLowerCase() + if (!value) return match + if (normalizedKey === "from") search.from = value + else if (normalizedKey === "to") search.to = value + else if (normalizedKey === "subject") search.subject = value + else if (normalizedKey === "after" && isDateInputValue(value)) search.startDate = value + else if (normalizedKey === "before" && isDateInputValue(value)) search.endDate = value + else if (normalizedKey === "has" && ["attachment", "attachments", "file", "files"].includes(normalizedValue)) search.hasAttachments = true + else if (normalizedKey === "is" && ["unread", "starred"].includes(normalizedValue)) { + if (normalizedValue === "unread") search.unread = true + if (normalizedValue === "starred") search.starred = true + } else { + return match + } + active = true + return " " + }).replace(/\s+/g, " ").trim() + return { active, query, search } +} + +function isDateInputValue(value: string) { + return /^\d{4}-\d{2}-\d{2}$/.test(value) +} + +function searchTextMatches(values: string[], needle: string) { + const normalized = needle.trim().toLowerCase() + if (!normalized) return true + return values.some((value) => value.toLowerCase().includes(normalized)) +} + function menuAnchorOrder(name: string) { switch (name) { case "Inbox": return 1000 - case "Sent": return 5000 - case "Drafts": return 6000 - case "Archive": return 7000 + case "Drafts": return 3000 + case "Sent": return 4000 + case "Archive": return 5000 + case "Trash": return 7000 case "Spam": return 8000 - case "Trash": return 9000 default: return 100000 } } +function AdvancedSearchPanel({ draft, onDraftChange, onSubmit, onClear }: { draft: AdvancedMailSearchDraft; onDraftChange: (draft: AdvancedMailSearchDraft) => void; onSubmit: (draft: AdvancedMailSearchDraft) => void; onClear: () => void }) { + const update = (key: K, value: AdvancedMailSearchDraft[K]) => onDraftChange({ ...draft, [key]: value }) + return ( +
{ + event.preventDefault() + onSubmit(draft) + }} + > +
+ + update("from", event.target.value)} placeholder="邮箱或名称" className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> + + + update("to", event.target.value)} placeholder="邮箱地址" className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> + + + update("subject", event.target.value)} placeholder="主题关键词" className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> + + + update("startDate", event.target.value)} className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> + + + update("endDate", event.target.value)} className="h-8 rounded-md bg-background px-2.5 text-[13px] shadow-none" /> + +
+
+ update("hasAttachments", !draft.hasAttachments)}>有附件 + update("unread", !draft.unread)}>未读 + update("starred", !draft.starred)}>星标 +
+
+ +
+ +
+
+
+ ) +} + +function AdvancedSearchField({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { + return ( + + ) +} + +function AdvancedSearchToggle({ checked, onClick, children }: { checked: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ) +} + +function SearchFilterChip({ label, onRemove }: { label: string; onRemove: () => void }) { + return ( + + {label} + + + ) +} + function FolderSkeleton() { return
} function MessageSkeleton() { return
{Array.from({ length: 6 }).map((_, i) =>
)}
} @@ -1592,7 +1890,7 @@ function getEmptyMessage(mailView: MailView, folder: string, total: number) { if (total > 0) return "当前筛选条件下没有邮件" if (mailView === "starred") return "暂无星标邮件" if (mailView === "label") return "当前标签没有邮件" - if (folder === "Inbox") return "收件箱暂时为空" + if (folder === "Inbox") return "暂无邮件" if (folder === "Drafts") return "还没有草稿" if (folder === "Sent") return "还没有已发送邮件" if (folder === "Trash") return "回收站是空的" @@ -2682,8 +2980,8 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe if (collapsed) { return (
- - {accountInitial(displayName, email)} + + {accountInitial(displayName, email)}
) @@ -2691,20 +2989,20 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe return (
- - {accountInitial(displayName, email)} + + {accountInitial(displayName, email)}
-
{displayName}
+
{displayName}
- - @@ -2717,7 +3015,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe ))} -
@@ -2725,28 +3023,61 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe ) } -function MailboxSwitcher({ collapsed, mailboxes, selectedMailbox, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailbox?: Mailbox; onSelect: (mailboxId: string) => void }) { +function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMailbox, fallbackAddress, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailboxId: string; selectedMailbox?: Mailbox; fallbackAddress?: string; onSelect: (mailboxId: string) => void }) { + const [mailboxQuery, setMailboxQuery] = React.useState("") + const isAllSelected = selectedMailboxId === "all" + const displayAddress = isAllSelected ? "全部邮箱" : selectedMailbox?.address || fallbackAddress || "选择邮箱" + const normalizedQuery = mailboxQuery.trim().toLowerCase() + const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery) + const filteredMailboxes = React.useMemo(() => { + if (!normalizedQuery) return mailboxes + return mailboxes.filter((mailbox) => { + const value = `${mailbox.address} ${mailbox.displayName || ""}`.toLowerCase() + return value.includes(normalizedQuery) + }) + }, [mailboxes, normalizedQuery]) return ( - + { if (!open) setMailboxQuery("") }}> - - + + {mailboxes.length > 0 && ( +
+ setMailboxQuery(event.target.value)} + onKeyDown={(event) => event.stopPropagation()} + placeholder="搜索邮箱..." + className="h-9 rounded-md bg-background px-2 text-sm shadow-none" + /> +
+ )} {mailboxes.length === 0 && 没有可用邮箱} - {mailboxes.map((mailbox) => ( - onSelect(mailbox.id)} className="gap-2"> - - {mailbox.address} + {mailboxes.length > 0 && showAllMailboxOption && ( + onSelect("all")} className={cn("h-8 gap-2 rounded-sm px-2 text-sm font-normal", isAllSelected && "bg-accent text-accent-foreground")}> + + 全部邮箱 + + )} + {filteredMailboxes.map((mailbox) => ( + onSelect(mailbox.id)} className={cn("h-8 min-w-0 gap-2 rounded-sm px-2 text-sm font-normal", !isAllSelected && selectedMailbox?.id === mailbox.id && "bg-accent text-accent-foreground")}> + + {mailbox.address} ))} + {mailboxes.length > 0 && !showAllMailboxOption && filteredMailboxes.length === 0 && ( + 没有匹配邮箱 + )}
) @@ -2970,7 +3301,7 @@ function MessageRow({ const visibleLabels = (message.labels || []).slice(0, 2) const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) const senderName = senderDisplayName(message) - return
+ return
-
{senderName}
+
{senderName}
{canOrganize &&
- {message.subject} + {message.subject || "无主题"} {scheduled && 已定时} {visibleLabels.map((label) => )} {hiddenLabelCount > 0 && +{hiddenLabelCount}} {message.hasAttachments && }
-
{message.snippet}
+
{message.snippet}
@@ -3013,12 +3344,16 @@ function MailLabelBadge({ label }: { label: MailLabel }) { const colors = generateLabelColor(label.name) return ( - + {label.name} ) } +function labelDotColor(label: MailLabel) { + return label.color?.trim() || generateLabelColor(label.name).backgroundColor +} + function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; limits?: PermissionLimits; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) { const { toast } = useToast() const qc = useQueryClient() diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 8d54cda..475d8c8 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -1,8 +1,7 @@ import * as React from "react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import type { ImperativePanelHandle } from "react-resizable-panels" import { useNavigate, useSearchParams } from "react-router-dom" -import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" +import { ArrowLeft, BarChart3, Ban, Clock3, Code2, Contact, Copy, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Repeat2, Search, SendHorizontal, Settings, Share2, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" @@ -27,26 +26,33 @@ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/s import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Separator } from "@/components/ui/separator" import { ScrollArea } from "@/components/ui/scroll-area" -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" -import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "@/components/ui/sidebar" import { ConfirmDialog } from "@/components/confirm-dialog" import { useToast } from "@/hooks/use-toast" -type Tab = "profile" | "apiTokens" | "mailboxes" | "clients" | "signatures" | "contacts" | "cleanup" | "rules" | "blocked" | "stats" +type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "sharing" | "blocked" | "transfer" | "stats" | "feedback" | "apiTokens" +type AccountSettingsTab = "account" | "mail" | "clients" | "security" type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void } const tabs: Record = { - profile: { label: "账户资料", icon: }, - apiTokens: { label: "API Token", icon: }, + profile: { label: "账号设置", icon: }, mailboxes: { label: "邮箱管理", icon: }, - clients: { label: "第三方客户端", icon: }, - signatures: { label: "签名管理", icon: }, contacts: { label: "联系人管理", icon: }, cleanup: { label: "邮件清理", icon: }, - rules: { label: "收件规则", icon: }, + cleanupQueue: { label: "待清理邮件", icon: }, + rules: { label: "收信规则", icon: }, + sharing: { label: "邮箱共享", icon: }, blocked: { label: "被拦截邮件", icon: }, + transfer: { label: "邮箱转让", icon: }, stats: { label: "数据统计", icon: }, + feedback: { label: "反馈", icon: }, + apiTokens: { label: "开发者", icon: }, } const tabKeys = Object.keys(tabs) as Tab[] +const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [ + { key: "account", label: "账号" }, + { key: "mail", label: "邮件" }, + { key: "clients", label: "通知与客户端" }, + { key: "security", label: "安全" }, +] const actionLabels: Record = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读" } export function ProfilePage() { @@ -57,8 +63,6 @@ export function ProfilePage() { const { toast } = useToast() const passwordFormRef = React.useRef(null) const twoFactorFormRef = React.useRef(null) - const sidebarPanelRef = React.useRef(null) - const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false) const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [displayMode, setDisplayMode] = useDisplayMode() @@ -70,6 +74,7 @@ export function ProfilePage() { const themeMountedRef = React.useRef(false) const rawTab = params.get("tab") as Tab | null + const rawAccountTab = params.get("accountTab") as AccountSettingsTab | null const user = me.data?.user const canAccessMail = hasPermission(user, "mail.access") const canReadMail = hasPermission(user, "mail.messages.read") @@ -83,18 +88,21 @@ export function ProfilePage() { const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply") const visibleTabKeys = tabKeys.filter((key) => { if (key === "profile") return true - if (key === "apiTokens") return true if (key === "mailboxes") return canAccessMail || canApplyMailbox - if (key === "clients") return canAccessMail - if (key === "signatures") return canManageSignatures if (key === "contacts") return canManageContacts if (key === "cleanup") return canOrganizeMail + if (key === "cleanupQueue") return canOrganizeMail if (key === "rules") return canManageRules + if (key === "sharing") return canAccessMail if (key === "blocked") return canManageBlocked + if (key === "transfer") return canAccessMail if (key === "stats") return canViewStats + if (key === "feedback") return true + if (key === "apiTokens") return true return false }) const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile" + const accountTab: AccountSettingsTab = rawAccountTab && accountSettingTabs.some((item) => item.key === rawAccountTab) ? rawAccountTab : "account" const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) @@ -115,7 +123,7 @@ export function ProfilePage() { const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId) const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) - const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) }) + const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels || canManageRules) }) const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats }) const profile = useMutation({ @@ -212,6 +220,16 @@ export function ProfilePage() { onError: (error) => toast({ title: "保存失败", description: error.message }), }) const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) } }) + const createLabel = useMutation({ + mutationFn: (form: FormData) => api.createLabel({ mailboxId: activeMailboxId, name: String(form.get("name") || ""), color: String(form.get("color") || "") }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已创建" }) }, + onError: (error) => toast({ title: "创建失败", description: error.message }), + }) + const deleteLabel = useMutation({ + mutationFn: (id: string) => api.deleteLabel(id, activeMailboxId), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已删除" }) }, + onError: (error) => toast({ title: "删除失败", description: error.message }), + }) const cleanup = useMutation({ mutationFn: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => api.cleanupMail({ mailboxId, target }), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `已处理 ${res.affected} 封邮件` }) }, @@ -280,81 +298,132 @@ export function ProfilePage() { async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) } function setTab(next: Tab) { const visibleNext = visibleTabKeys.includes(next) ? next : "profile" - setParams(visibleNext === "profile" ? {} : { tab: visibleNext }) + const nextParams = new URLSearchParams(params) + if (visibleNext === "profile") nextParams.delete("tab") + else { + nextParams.set("tab", visibleNext) + nextParams.delete("accountTab") + } + setParams(nextParams) setMobileSidebarOpen(false) } - function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) } + function setAccountTab(next: AccountSettingsTab) { + const nextParams = new URLSearchParams(params) + nextParams.delete("tab") + if (next === "account") nextParams.delete("accountTab") + else nextParams.set("accountTab", next) + setParams(nextParams) + } if (me.isLoading) return
加载中...
if (me.isError || !user) return
登录状态已失效
const sidebarContent = ( - - - setDarkMode((v) => !v)} onBack={() => navigate("/")} /> - - - - {!sidebarCollapsed && 个人中心} - - {visibleTabKeys.map((key) => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && {tabs[key].label}})} - - - -
- - {!isMobile && ( - <> - - - - )} + ) return (
- - {isMobile ? ( -
-
- - - - - - 个人中心导航 -
{sidebarContent}
-
-
-
{tabs[tab].label}
- -
-
{renderTab()}
-
- ) : ( - - setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}> - {sidebarContent} - - - -
-
-
{tabs[tab].label}
-
-
{renderTab()}
-
-
-
- )} -
+ {isMobile ? ( +
+
+ + + + + + 管理导航 +
{sidebarContent}
+
+
+
{tabs[tab].label}
+ +
+ +
+ +
{renderTab()}
+
+
+
+ ) : ( +
+ {sidebarContent} +
+
+ +
{renderTab()}
+
+
+
+ )}
) function renderTab() { + if (tab === "profile") return ( + createLabel.mutate(form)} + onDeleteLabel={(id) => deleteLabel.mutate(id)} + signatures={signatures.data?.items || []} + signaturesLoading={signatures.isLoading} + signaturesPending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} + onCreateSignature={(form) => createSignature.mutate(form)} + onUpdateSignature={(id, form) => updateSignature.mutate({ id, form })} + onSetDefaultSignature={(id) => setDefaultSignature.mutate(id)} + onDeleteSignature={(id) => deleteSignature.mutate(id)} + clientHostname={publicSettings.data?.publicHostname} + onSelectMailbox={setMailboxId} + onOpenCleanup={() => setTab("cleanup")} + /> + ) if (tab === "mailboxes") return ( ) if (tab === "apiTokens") return createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} /> - if (tab === "clients") return - if (tab === "signatures") return createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} /> if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> if (tab === "cleanup") return cleanup.mutate(target)} /> - if (tab === "rules") return createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> + if (tab === "cleanupQueue") return + if (tab === "rules") return createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> + if (tab === "sharing") return if (tab === "blocked") return f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> + if (tab === "transfer") return if (tab === "stats") return stats.refetch()} /> - return + if (tab === "feedback") return + if (tab === "apiTokens") return createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} /> + return null } } +function SettingsPageHeader({ title, activeTab, onAccountTabChange }: { title: string; activeTab?: AccountSettingsTab; onAccountTabChange: (tab: AccountSettingsTab) => void }) { + return ( +
+

{title}

+ {activeTab && ( +
+ {accountSettingTabs.map((item) => ( + + ))} +
+ )} +
+ ) +} + +type AccountSettingsSectionProps = { + activeTab: AccountSettingsTab + user: { id: string; email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits } + profile: { mutate: (form: FormData) => void; isPending: boolean } + password: { mutate: (form: FormData) => void; isPending: boolean } + passwordFormRef: React.RefObject + stats?: MailStats + showStats: boolean + displayMode: DisplayMode + onDisplayModeChange: (mode: DisplayMode) => void + twoFactorFormRef: React.RefObject + setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean } + enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean } + disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean } + onCopy: (text: string) => void + mailboxes: Mailbox[] + selectedMailboxId: string + selectedMailbox?: Mailbox + labels: MailLabel[] + labelsLoading: boolean + labelsPending: boolean + onCreateLabel: (form: FormData) => void + onDeleteLabel: (id: string) => void + signatures: MailSignature[] + signaturesLoading: boolean + signaturesPending: boolean + onCreateSignature: (form: FormData) => void + onUpdateSignature: (id: string, form: FormData) => void + onSetDefaultSignature: (id: string) => void + onDeleteSignature: (id: string) => void + clientHostname?: string + onSelectMailbox: (id: string) => void + onOpenCleanup: () => void +} + +function AccountSettingsSection(props: AccountSettingsSectionProps) { + if (props.activeTab === "mail") { + return ( + + ) + } + if (props.activeTab === "clients") { + return + } + if (props.activeTab === "security") { + return ( + + ) + } + return ( + + ) +} + +function SettingsCard({ title, subtitle, action, children, className, contentClassName }: { title: string; subtitle?: string; action?: React.ReactNode; children: React.ReactNode; className?: string; contentClassName?: string }) { + return ( +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action} +
+
{children}
+
+ ) +} + +function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) { + const accountName = cleanAccountName(user.displayName || user.email, user.email) + const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0) + const storageBytes = stats?.storageBytes || 0 + const quotaPct = quotaBytes > 0 ? Math.min(100, Math.round((storageBytes / quotaBytes) * 100)) : 0 + return ( +
+ +
+ + +
+ + +
+
+
+ + 邮件清理}> +
+ {quotaBytes > 0 ? `${formatBytes(storageBytes)} / ${formatBytes(quotaBytes)}` : formatBytes(storageBytes)} + {quotaBytes > 0 ? `${quotaPct}%` : "不限"} +
+
+
0 ? quotaPct : 12}%` }} /> +
+ + + 实时按当前账号配置计算}> +
+ + + + + +
+
+ + +
+ {["NodeSeek Mail v3 风格设置页", "智能搜索与邮件列表", "自建邮箱管理能力"].map((title, index) => ( +
+
+ v{3 - index}.0.0 + · + {title} +
+

持续完善邮箱体验、账号管理和私有化部署功能。

+
+ ))} +
+
+
+ ) +} + +function InfoLine({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function QuotaBox({ title, lines, highlight, className }: { title: string; lines: string[]; highlight?: string; className?: string }) { + return ( +
+
+
{title}
+ {highlight && {highlight}} +
+
+ {lines.map((line) =>
{line}
)} +
+
+ ) +} + +function MailPreferencesSection({ + labels, + labelsLoading, + labelsPending, + onCreateLabel, + onDeleteLabel, + selectedMailbox, + signatures, + signaturesLoading, + signaturesPending, + mailboxes, + onCreateSignature, + onUpdateSignature, + onSetDefaultSignature, + onDeleteSignature, +}: { + labels: MailLabel[] + labelsLoading: boolean + labelsPending: boolean + onCreateLabel: (form: FormData) => void + onDeleteLabel: (id: string) => void + selectedMailbox?: Mailbox + signatures: MailSignature[] + signaturesLoading: boolean + signaturesPending: boolean + mailboxes: Mailbox[] + onCreateSignature: (form: FormData) => void + onUpdateSignature: (id: string, form: FormData) => void + onSetDefaultSignature: (id: string) => void + onDeleteSignature: (id: string) => void +}) { + const [labelColor, setLabelColor] = React.useState("#3b82f6") + const [signatureMailboxId, setSignatureMailboxId] = React.useState("all") + const [signatureDefault, setSignatureDefault] = React.useState(false) + const [editingSignature, setEditingSignature] = React.useState(null) + const [pendingConfirm, setPendingConfirm] = React.useState(null) + const [whitelist, setWhitelist] = React.useState(() => readLocalStringList("lanqin:mail-whitelist")) + const [imageKey, setImageKey] = React.useState(() => readLocalString("lanqin:image-api-key")) + const [autoReplyEnabled, setAutoReplyEnabled] = React.useState(() => readLocalString("lanqin:auto-reply-enabled") === "1") + const [autoReplyText, setAutoReplyText] = React.useState(() => readLocalString("lanqin:auto-reply-text")) + + React.useEffect(() => { writeLocalStringList("lanqin:mail-whitelist", whitelist) }, [whitelist]) + React.useEffect(() => { writeLocalString("lanqin:image-api-key", imageKey) }, [imageKey]) + React.useEffect(() => { writeLocalString("lanqin:auto-reply-enabled", autoReplyEnabled ? "1" : "0") }, [autoReplyEnabled]) + React.useEffect(() => { writeLocalString("lanqin:auto-reply-text", autoReplyText) }, [autoReplyText]) + + function submitLabel(event: React.FormEvent) { + event.preventDefault() + if (!selectedMailbox) return + const form = new FormData(event.currentTarget) + form.set("color", labelColor) + onCreateLabel(form) + event.currentTarget.reset() + setLabelColor("#3b82f6") + } + + function submitWhitelist(event: React.FormEvent) { + event.preventDefault() + const form = new FormData(event.currentTarget) + const value = String(form.get("whitelist") || "").trim() + if (!value || whitelist.includes(value)) return + setWhitelist((items) => [value, ...items]) + event.currentTarget.reset() + } + + function submitSignature(event: React.FormEvent) { + event.preventDefault() + const form = new FormData(event.currentTarget) + form.set("mailboxId", signatureMailboxId === "all" ? "" : signatureMailboxId) + form.set("isDefault", signatureDefault ? "on" : "") + onCreateSignature(form) + event.currentTarget.reset() + setSignatureMailboxId("all") + setSignatureDefault(false) + } + + return ( +
+ +
+ + setLabelColor(event.target.value)} className="h-10 w-12 cursor-pointer rounded-md border border-input bg-background p-1" aria-label="标签颜色" /> + +
+
+ {labels.map((label) => ( + + + {label.name} + + + ))} + {!labelsLoading && labels.length === 0 && 暂无标签} +
+
+ + +
+ + +
+
+ {whitelist.map((item) => ( +
+ {item} + +
+ ))} + {whitelist.length === 0 &&
暂无白名单
} +
+
+ + 共 {signatures.length} 个签名}> +
+
+ + +
+ +