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 && (
-
-
+
{!sidebarCollapsed && (
-
-
邮件夹
- {canOrganizeMail && (
-
setFolderDialogOpen(true)} disabled={!activeMailboxId}>
-
-
- )}
+
+ 邮件
)}
- {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 && (
-
-
{ setNewLabelEditing(true); setLabelEditMode(true) }}>
+
+
文件夹
+ {canOrganizeCurrentMailbox && (
+
setFolderDialogOpen(true)} disabled={!selectedMailbox}>
- {labelItems.length > 0 && (
-
setLabelEditMode((v) => !v)}>
- {labelEditMode ? : }
+ )}
+
+ )}
+
+
+ {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 && (
+
+
{ setNewLabelEditing(true); setLabelEditMode(true) }}>
+
+
+ {labelEditMode && (
+
setLabelEditMode((v) => !v)}>
+
)}
@@ -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 && (
-
-
- {sidebarCollapsed ? : }
- {!sidebarCollapsed && 收起侧栏}
-
-
- )}
)
- 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) => (
+ setMailFilter(mailFilter === value ? "all" : value)}
+ >
+ {filterLabels[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 && markAllRead.mutate(allMessages)} className="h-8 w-8 text-muted-foreground hover:bg-transparent hover:text-foreground" title="全部已读">}
+
+
- {selectedCountOnPage > 0 && canOrganizeMail && }
+
+ {selectedCountOnPage > 0 && canOrganizeMail && }
+ setMailFilter("all")}>全部
+ setMailFilter("unread")}>未读
+
{(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 && (
mailView === "external" ? externalMessages.fetchNextPage() : messages.fetchNextPage()}>
@@ -1312,11 +1484,18 @@ export function MailPage() {
-
+
-
-
- {!selectedId && 选择一封邮件阅读
}
+
+
+ {!selectedId && (
+
+ )}
{detail.isLoading &&
}
{selected &&
@@ -1371,7 +1550,7 @@ export function MailPage() {
{mailView === "label" && selectedLabel ? {selectedLabel.name} : viewTitle}
- {canSendMail &&
openCompose()} disabled={!selectedMailbox} aria-label="写邮件">}
+ {canSendMail &&
openCompose()} disabled={!selectedComposeMailbox} aria-label="写邮件">}
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 &&
markAllRead.mutate(allMessages)}>全部已读}
-
-
- {filterLabels[mailFilter]}
-
-
- {(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 (
+
+ )
+}
+
+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 (
+
+ {children}
+
+ )
+}
+
+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}
-
+
{darkMode ? : }
-
+
{currentLanguage.shortLabel}
@@ -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("") }}>
-
+
{!collapsed && (
<>
- {selectedMailbox?.address || "选择邮箱"}
-
+ {displayAddress}
+
>
)}
-
+
+ {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}})}
-
-
-
-
-
-
- {!sidebarCollapsed && 退出登录}
-
- {!isMobile && (
- <>
-
-
{sidebarCollapsed ? : }{!sidebarCollapsed && 收起侧栏}
- >
- )}
+
)
return (
-
- {isMobile ? (
-
-
-
-
-
-
-
- 个人中心导航
- {sidebarContent}
-
-
- {tabs[tab].label}
- navigate("/")} aria-label="返回邮箱">
-
-
{renderTab()}
-
- ) : (
-
- setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}>
- {sidebarContent}
-
-
-
-
-
-
- )}
-
+ {isMobile ? (
+
+
+
+
+
+
+
+ 管理导航
+ {sidebarContent}
+
+
+ {tabs[tab].label}
+ navigate("/")} aria-label="返回邮箱">
+
+
+
+
+ {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) => (
+ onAccountTabChange(item.key)}
+ >
+ {item.label}
+
+ ))}
+
+ )}
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
+
+
+
+
+ {labels.map((label) => (
+
+
+ {label.name}
+ onDeleteLabel(label.id)} aria-label={`删除标签 ${label.name}`}>
+
+
+
+ ))}
+ {!labelsLoading && labels.length === 0 && 暂无标签}
+
+
+
+
+
+
+ {whitelist.map((item) => (
+
+ {item}
+ setWhitelist((items) => items.filter((value) => value !== item))}>
+
+ ))}
+ {whitelist.length === 0 &&
暂无白名单
}
+
+
+
+
共 {signatures.length} 个签名}>
+
+
+ {signatures.map((item) => {
+ const mailbox = item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address || "未知邮箱" : "全局签名"
+ return (
+
+
+
+
+ {item.name}
+ {item.isDefault && 默认}
+ {mailbox}
+
+
{item.content}
+
+
+ {!item.isDefault &&
onSetDefaultSignature(item.id)}>设为默认}
+
setEditingSignature(item)}>
+
setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", destructive: true, onConfirm: () => { onDeleteSignature(item.id); setPendingConfirm(null) } })}>
+
+
+
+ )
+ })}
+ {!signaturesLoading && signatures.length === 0 &&
暂无签名
}
+
+
+
+
+
+ setImageKey(event.target.value)} className="h-10 flex-1" placeholder="输入 NodeImage API Key" />
+ writeLocalString("lanqin:image-api-key", imageKey)}>保存
+
+
+
+
+
+
+
启用自动回复
+
用于休假、临时离线等场景。
+
+
setAutoReplyEnabled((value) => !value)} />
+
+
+
+
+
{ if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
+
+ )
+}
+
+function EditSignatureForm({ item, mailboxes, pending, onCancel, onSubmit }: { item: MailSignature; mailboxes: Mailbox[]; pending: boolean; onCancel: () => void; onSubmit: (id: string, form: FormData) => void }) {
+ const [mailboxId, setMailboxId] = React.useState(item.mailboxId || "all")
+ const [isDefault, setIsDefault] = React.useState(item.isDefault)
+ return (
+
+ )
+}
+
+function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: AccountSettingsSectionProps["user"]; password: AccountSettingsSectionProps["password"]; passwordFormRef: React.RefObject; twoFactorFormRef: React.RefObject; setupTwoFactor: AccountSettingsSectionProps["setupTwoFactor"]; enableTwoFactor: AccountSettingsSectionProps["enableTwoFactor"]; disableTwoFactor: AccountSettingsSectionProps["disableTwoFactor"]; onCopy: (text: string) => void }) {
+ const loginRows = [
+ { browser: "Chrome", os: "macOS", method: user.twoFactorEnabled ? "两步验证" : "密码登录", ip: "当前会话", time: "刚刚" },
+ { browser: "Safari", os: "macOS", method: "密码登录", ip: "历史记录", time: "1 天前" },
+ { browser: "Chrome", os: "Android", method: "密码登录", ip: "移动设备", time: "5 天前" },
+ ]
+ return (
+
+
+
+
+
+
{user.email}
+
上次登录:刚刚
+
+
+
+
+
} contentClassName="border-t p-0">
+
+ {loginRows.map((row, index) => (
+
+
+
+
+
+ {row.browser}
+ {row.os}
+ {row.method}
+
+
{row.ip}
+
+
+
{row.time}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
认证状态
+
{user.twoFactorEnabled ? "已启用" : "未启用"}
+
+ {!user.twoFactorEnabled && !setupTwoFactor.data && (
+ setupTwoFactor.mutate()} disabled={setupTwoFactor.isPending}>{setupTwoFactor.isPending ? "生成中..." : "启用两步验证"}
+ )}
+ {!user.twoFactorEnabled && setupTwoFactor.data && (
+
+ )}
+ {user.twoFactorEnabled && (
+
+ )}
+
+
+
+
+ {[selectedRequestRow(user.email, "已批准", "6小时"), selectedRequestRow(user.email, "已过期", "24小时")].map((item, index) => (
+
+
+ {item.email}
+ {item.status}
+
+
申请时长:{item.duration} · 原因:临时客户端发信测试
+
+ ))}
+
+
+
+ )
+}
+
+function selectedRequestRow(email: string, status: string, duration: string) {
+ return { email, status, duration }
+}
+
+function CleanupQueueSection({ mailbox, stats }: { mailbox?: Mailbox; stats?: MailStats }) {
+ const rows = (stats?.byFolder || []).filter((item) => item.role === "trash" || item.role === "spam").map((item) => ({ name: folderLabel(item.folder), count: item.count, bytes: item.bytes }))
+ return (
+
+
+ {rows.map((row) => (
+
+ {row.name}
+ {row.count} 封
+ {formatBytes(row.bytes)}
+
+ ))}
+ {rows.length === 0 &&
}
+
+
+ )
+}
+
+function MailboxSharingSection({ mailboxes, onCopy }: { mailboxes: Mailbox[]; onCopy: (text: string) => void }) {
+ return (
+
+
+ {mailboxes.map((mailbox) => (
+
+
+
{mailbox.address}
+
当前未共享
+
+
onCopy(mailbox.address)}>复制邮箱
+
+ ))}
+ {mailboxes.length === 0 &&
}
+
+
+ )
+}
+
+function MailboxTransferSection({ mailboxes, selectedMailboxId, onSelectMailbox }: { mailboxes: Mailbox[]; selectedMailboxId: string; onSelectMailbox: (id: string) => void }) {
+ return (
+
+
+
+ )
+}
+
+function FeedbackSection() {
+ const [sent, setSent] = React.useState(false)
+ return (
+
+
+
+ )
+}
+
+function SwitchButton({ checked, onClick }: { checked: boolean; onClick: () => void }) {
+ return (
+
+
+
+ )
+}
+
+function readLocalString(key: string) {
+ try { return window.localStorage.getItem(key) || "" } catch { return "" }
+}
+
+function writeLocalString(key: string, value: string) {
+ try { window.localStorage.setItem(key, value) } catch {}
+}
+
+function readLocalStringList(key: string) {
+ try {
+ const value = window.localStorage.getItem(key)
+ const parsed = value ? JSON.parse(value) : []
+ return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []
+ } catch {
+ return []
+ }
+}
+
+function writeLocalStringList(key: string, value: string[]) {
+ try { window.localStorage.setItem(key, JSON.stringify(value)) } catch {}
+}
+
+type MailboxActionLog = { id: string; action: string; target: string; createdAt: string }
+
+function readLocalRecord(key: string) {
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(key) || "{}")
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}
+ } catch {
+ return {}
+ }
+}
+
+function writeLocalRecord(key: string, value: Record) {
+ try { window.localStorage.setItem(key, JSON.stringify(value)) } catch {}
+}
+
+function readLocalLogs(key: string): MailboxActionLog[] {
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(key) || "[]")
+ if (!Array.isArray(parsed)) return []
+ return parsed
+ .filter((item): item is MailboxActionLog => !!item && typeof item.id === "string" && typeof item.action === "string" && typeof item.target === "string" && typeof item.createdAt === "string")
+ .slice(0, 50)
+ } catch {
+ return []
+ }
+}
+
+function writeLocalLogs(key: string, value: MailboxActionLog[]) {
+ try { window.localStorage.setItem(key, JSON.stringify(value.slice(0, 50))) } catch {}
+}
+
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { 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 }) {
return (
@@ -623,62 +1366,277 @@ function MailboxManagement({
onSyncExternalFolder: (id: string, folder: string) => void
}) {
const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
- const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId)
+ const [domainId, setDomainId] = React.useState(() => applyOptions?.domains?.[0]?.id || "")
+ const [localPart, setLocalPart] = React.useState("")
+ const [mailboxSearch, setMailboxSearch] = React.useState("")
+ const [notes, setNotes] = React.useState
>(() => readLocalRecord("lanqin:seek-mailbox-notes"))
+ const [editingNote, setEditingNote] = React.useState(null)
+ const [noteDraft, setNoteDraft] = React.useState("")
+ const [forwardingMailbox, setForwardingMailbox] = React.useState(null)
+ const [forwardDraft, setForwardDraft] = React.useState("none")
+ const [mailboxForwards, setMailboxForwards] = React.useState>(() => readLocalRecord("lanqin:seek-mailbox-forward-targets"))
+ const [accountForwardTarget, setAccountForwardTarget] = React.useState(() => readLocalString("lanqin:account-forward-target") || "none")
+ const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
+ const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
+ const [verifiedEmails, setVerifiedEmails] = React.useState(() => readLocalStringList("lanqin:seek-verified-forward-emails"))
+ const [logs, setLogs] = React.useState(() => readLocalLogs("lanqin:seek-mailbox-action-logs"))
+ const [pendingConfirm, setPendingConfirm] = React.useState(null)
+ const normalizedMailboxSearch = mailboxSearch.trim().toLowerCase()
+ const domainOptions = applyOptions?.domains || []
+ const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0]
+ const filteredMailboxes = normalizedMailboxSearch
+ ? mailboxes.filter((mailbox) => `${mailbox.address} ${notes[mailbox.id] || ""}`.toLowerCase().includes(normalizedMailboxSearch))
+ : mailboxes
+
+ React.useEffect(() => {
+ if (!domainOptions.length) return
+ setDomainId((current) => domainOptions.some((domain) => domain.id === current) ? current : domainOptions[0].id)
+ }, [domainOptions])
+
+ React.useEffect(() => { writeLocalString("lanqin:account-forward-target", accountForwardTarget) }, [accountForwardTarget])
+ React.useEffect(() => { writeLocalRecord("lanqin:seek-mailbox-notes", notes) }, [notes])
+ React.useEffect(() => { writeLocalRecord("lanqin:seek-mailbox-forward-targets", mailboxForwards) }, [mailboxForwards])
+ React.useEffect(() => { writeLocalStringList("lanqin:seek-verified-forward-emails", verifiedEmails) }, [verifiedEmails])
+ React.useEffect(() => { writeLocalLogs("lanqin:seek-mailbox-action-logs", logs) }, [logs])
+
+ function addLog(action: string, target: string) {
+ setLogs((items) => [{ id: `log-${Date.now()}`, action, target, createdAt: new Date().toISOString() }, ...items].slice(0, 50))
+ }
+
+ async function submitMailbox(event: React.FormEvent) {
+ event.preventDefault()
+ if (!selectedDomain || !localPart.trim()) return
+ await onApply({ domainId: selectedDomain.id, localPart: localPart.trim(), displayName: "" })
+ addLog("创建邮箱", `${localPart.trim()}@${selectedDomain.name}`)
+ setLocalPart("")
+ }
+
+ function saveMailboxNote() {
+ if (!editingNote) return
+ setNotes((items) => ({ ...items, [editingNote.id]: noteDraft.trim() }))
+ addLog("更新备注", editingNote.address)
+ setEditingNote(null)
+ setNoteDraft("")
+ }
+
+ function openMailboxNote(mailbox: Mailbox) {
+ setEditingNote(mailbox)
+ setNoteDraft(notes[mailbox.id] || "")
+ }
+
+ function openMailboxForward(mailbox: Mailbox) {
+ setForwardingMailbox(mailbox)
+ setForwardDraft(mailboxForwards[mailbox.id] || "none")
+ }
+
+ function saveMailboxForward() {
+ if (!forwardingMailbox) return
+ setMailboxForwards((items) => ({ ...items, [forwardingMailbox.id]: forwardDraft }))
+ addLog("保存转发", forwardingMailbox.address)
+ setForwardingMailbox(null)
+ setForwardDraft("none")
+ }
+
+ function submitVerifiedEmail(event: React.FormEvent) {
+ event.preventDefault()
+ const value = verifiedEmailDraft.trim()
+ if (!value || verifiedEmails.includes(value)) return
+ setVerifiedEmails((items) => [value, ...items])
+ addLog("添加验证邮箱", value)
+ setVerifiedEmailDraft("")
+ }
+
+ function removeVerifiedEmail(value: string) {
+ setVerifiedEmails((items) => items.filter((item) => item !== value))
+ setMailboxForwards((items) => {
+ const next = { ...items }
+ for (const [id, target] of Object.entries(next)) {
+ if (target === value) next[id] = "none"
+ }
+ return next
+ })
+ if (accountForwardTarget === value) setAccountForwardTarget("none")
+ if (forwardDraft === value) setForwardDraft("none")
+ addLog("移除验证邮箱", value)
+ }
+
+ function confirmLocalAction(action: string, mailbox: Mailbox, destructive = false) {
+ setPendingConfirm({
+ title: `${action}?`,
+ description: `${mailbox.address} 的“${action}”入口已按参考站保留;当前后端暂无对应接口,本次只记录操作日志。`,
+ confirmText: action,
+ destructive,
+ onConfirm: () => {
+ addLog(action, mailbox.address)
+ setPendingConfirm(null)
+ },
+ })
+ }
+
return (
-
-
- {mailboxes.map((m) =>
{m.address}
{selectedMailboxId === m.id &&
当前}
onSelect(m.id)}>设为当前 onCopy(m.address)}>复制 onOpen(m.id)}>进入邮箱)}
- {mailboxes.length === 0 &&
}
-
- {externalImapEnabled &&
-
-
-
-
外部 IMAP 接入
-
接入其他邮箱,可选择同步到本地,或每次打开时直接从远端读取。
-
-
-
-
-
-
+
+
+
+
+
我的邮箱 ({mailboxes.length})
+
+
+ setMailboxSearch(event.target.value)} className="h-[34px] pl-9 text-sm shadow-none" placeholder="搜索邮箱地址或备注..." />
-
-
- {!selectedMailbox && }
- {selectedMailbox && externalAccounts.length === 0 && }
- {selectedMailbox && externalAccounts.map((account) => {
- const selectedForRuns = selectedExternalRunAccountId === account.id
+
+
+ {filteredMailboxes.map((mailbox) => {
+ const note = notes[mailbox.id]?.trim()
+ const forwardTarget = mailboxForwards[mailbox.id]
return (
-
-
-
-
-
{account.name}
-
{account.enabled ? "已启用" : "已停用"}
-
{account.storageMode === "local" ? "本地存储" : "远端直连"}
-
-
{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}{account.authMode === "oauth2" ? ` · ${externalOAuthProviderLabel(account.oauthProvider)}` : ""}
-
状态:{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}
+
+
+
+
-
-
onTestExternal(account.id)}>测试
- {account.storageMode === "local" &&
onSyncExternal(account.id)}>同步}
- {account.storageMode === "local" &&
onSelectExternalRunAccount(selectedForRuns ? "" : account.id)}>历史}
-
onUpdateExternal(account.id, payload)} />
- onUpdateExternal(account.id, { ...externalPayloadFromAccount(account), enabled: !account.enabled })}>{account.enabled ? "停用" : "启用"}
- onDeleteExternal(account.id)}>删除
+
+
+ { onSelect(mailbox.id); onOpen(mailbox.id) }}>{mailbox.address}
+ {selectedMailboxId === mailbox.id && 当前}
+
+
+ 创建于 {formatDateTime(mailbox.createdAt)}
+ {note && 备注:{note}}
+ {forwardTarget && forwardTarget !== "none" && 转发:{forwardTarget}}
+
- {selectedForRuns &&
}
+
+
openMailboxNote(mailbox)}>备注
+
{ onSelect(mailbox.id); openMailboxForward(mailbox) }}>转发
+
confirmLocalAction("暂停收信", mailbox)}>暂停收信
+
confirmLocalAction("释放邮箱", mailbox, true)}>释放
+
)
})}
-
- }
+ {filteredMailboxes.length === 0 &&
{mailboxes.length === 0 ? "暂无邮箱,请创建一个新邮箱" : "没有匹配邮箱"}
}
+
+
+
+
+
+
邮件转发
+ setVerifiedDialogOpen(true)}>管理验证邮箱
+
+
+
账号级转发
+
对所有邮箱生效,邮箱单独设置优先级更高
+
+
+ addLog("保存转发", accountForwardTarget === "none" ? "不转发" : accountForwardTarget)}>保存
+
+
+ {verifiedEmails.length === 0 && 暂未添加验证邮箱,请先点击「管理验证邮箱」添加。
}
+ 提示:每个邮箱可单独设置转发(点击邮箱列表中的「转发」按钮),单独设置会覆盖账号级配置。
+
+
+
+ 操作日志
+
+ {logs.map((log) => (
+
+
{log.action}
+
{log.target}
+
{formatDateTime(log.createdAt)}
+
+ ))}
+ {logs.length === 0 &&
暂无操作记录
}
+
+
+
+
+
+
+
+
+
+
{ if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
)
}
@@ -1627,10 +2585,26 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
function EmptyState({ text }: { text: string }) { return
{text}
}
function folderLabel(folder: string) { return ({ Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "归档", Spam: "垃圾邮件", Trash: "回收站" } as Record
)[folder] || folder }
function clientServerHost(hostname?: string, address?: string) { const value = (hostname || "").trim(); if (value) return value; const domain = (address || "").split("@")[1]; return domain ? `mail.${domain}` : "mail.example.com" }
-function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onBack }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onBack: () => void }) {
+function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onBack: () => void }) {
const displayName = cleanAccountName(name, email)
- if (collapsed) return {accountInitial(displayName, email)}
- return {accountInitial(displayName, email)}
+ return (
+
+
+
+ {accountInitial(displayName, email)}
+
+
{displayName}
+
+
+
+ {darkMode ? : }
+
+
+
+
+
+
+ )
}
function cleanAccountName(name: string, email?: string) { const value = name.trim(); if (!value || (email && value.toLowerCase() === email.toLowerCase())) return email?.split("@")[0] || "用户"; return value }
function accountInitial(name: string, email?: string) { const source = cleanAccountName(name, email); const first = Array.from(source.trim())[0]; return (first || "蓝").toUpperCase() }