fix: align mail statistics dashboard

This commit is contained in:
zxyszx
2026-08-03 00:16:03 +08:00
parent 92ff7dbfaf
commit a8170dfe4d
6 changed files with 481 additions and 40 deletions
+22 -1
View File
@@ -5289,12 +5289,24 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
t.Fatalf("alice login code=%d", code) t.Fatalf("alice login code=%d", code)
} }
var stats MailStats var stats MailStats
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID, nil, &stats); code != http.StatusOK { if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK {
t.Fatalf("stats code=%d stats=%+v", code, stats) t.Fatalf("stats code=%d stats=%+v", code, stats)
} }
if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 { if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
t.Fatalf("stats quota/attachment not populated: %+v", stats) t.Fatalf("stats quota/attachment not populated: %+v", stats)
} }
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
t.Fatalf("stats message totals not populated: %+v", stats)
}
if len(stats.Trend) != 7 || stats.Trend[len(stats.Trend)-1].Incoming != 1 {
t.Fatalf("stats trend not populated: %+v", stats.Trend)
}
if !mailStatsDistributionHas(stats.Distribution, "trash", 1) || !mailStatsDistributionHas(stats.Distribution, "attachments", 1) {
t.Fatalf("stats distribution not populated: %+v", stats.Distribution)
}
if len(stats.TopContacts) == 0 || stats.TopContacts[0].Email != "sender@example.test" || stats.TopContacts[0].Count != 1 {
t.Fatalf("stats top contacts not populated: %+v", stats.TopContacts)
}
var cleanup struct { var cleanup struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Affected int64 `json:"affected"` Affected int64 `json:"affected"`
@@ -5317,6 +5329,15 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
} }
} }
func mailStatsDistributionHas(items []MailStatsDistributionItem, key string, count int64) bool {
for _, item := range items {
if item.Key == key && item.Count == count {
return true
}
}
return false
}
func mustDefaultDomainID(t *testing.T, a *App) string { func mustDefaultDomainID(t *testing.T, a *App) string {
t.Helper() t.Helper()
var id string var id string
+209 -4
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -641,6 +642,7 @@ func (a *App) handleDeleteBlockedSender(w http.ResponseWriter, r *http.Request)
func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) { func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
rangeDays := mailStatsRangeDays(r.URL.Query().Get("days"))
args := []any{user.ID} args := []any{user.ID}
where := `mb.user_id=?` where := `mb.user_id=?`
if mailboxID != "" && !isAllMailboxID(mailboxID) { if mailboxID != "" && !isAllMailboxID(mailboxID) {
@@ -651,17 +653,54 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
where += ` AND mb.id=?` where += ` AND mb.id=?`
args = append(args, mailboxID) args = append(args, mailboxID)
} }
stats := MailStats{ByFolder: []MailStatsFolderCount{}} now := a.now().UTC()
row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0) stats := MailStats{
FROM mailboxes mb LEFT JOIN messages m ON m.mailbox_id=mb.id WHERE `+where, args...) ByFolder: []MailStatsFolderCount{},
if err := row.Scan(&stats.TotalMessages, &stats.UnreadMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil { Trend: emptyMailStatsTrend(now, rangeDays),
Distribution: []MailStatsDistributionItem{},
TopContacts: []MailStatsContact{},
}
row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id),
COALESCE(SUM(CASE WHEN f.role NOT IN ('sent','drafts') THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN f.role='drafts' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),
COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb
LEFT JOIN messages m ON m.mailbox_id=mb.id
LEFT JOIN folders f ON f.id=m.folder_id
WHERE `+where, args...)
if err := row.Scan(&stats.TotalMessages, &stats.TotalIncoming, &stats.TotalOutgoing, &stats.UnreadMessages, &stats.DraftMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load stats") respondError(w, http.StatusInternalServerError, "failed to load stats")
return return
} }
if stats.TotalMessages > 0 {
stats.AverageMessageBytes = stats.StorageBytes / stats.TotalMessages
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil { if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load attachment stats") respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
return return
} }
var attachmentMessageCount int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id) FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id WHERE `+where+` AND m.has_attachments=1`, args...).Scan(&attachmentMessageCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load attachment message stats")
return
}
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
todayArgs := append(append([]any{}, args...), todayStart)
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id)
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE `+where+` AND f.role='sent' AND m.sent_at>=?`, todayArgs...).Scan(&stats.TodayOutgoing); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load today stats")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(sq.id)
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id
WHERE `+where+` AND sq.status='failed'`, args...).Scan(&stats.FailedSends); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
return
}
if mailboxID != "" && !isAllMailboxID(mailboxID) { if mailboxID != "" && !isAllMailboxID(mailboxID) {
var quotaMB int64 var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil { if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil {
@@ -672,6 +711,16 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
if stats.QuotaBytes > 0 { if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100 stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
} }
} else {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
} }
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0) rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
@@ -689,9 +738,165 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
} }
stats.ByFolder = append(stats.ByFolder, item) stats.ByFolder = append(stats.ByFolder, item)
} }
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folder stats")
return
}
stats.Distribution = mailStatsDistribution(stats.ByFolder, attachmentMessageCount, stats.StarredMessages)
if err := a.loadMailStatsTrend(r.Context(), where, args, rangeDays, stats.Trend); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load trend stats")
return
}
topContacts, err := a.mailStatsTopContacts(r.Context(), where, args)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load contact stats")
return
}
stats.TopContacts = topContacts
respondJSON(w, http.StatusOK, stats) respondJSON(w, http.StatusOK, stats)
} }
func mailStatsRangeDays(raw string) int {
days, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || days <= 0 {
return 30
}
switch days {
case 7, 30, 90, 365:
return days
default:
if days < 7 {
return 7
}
if days > 365 {
return 365
}
return days
}
}
func emptyMailStatsTrend(now time.Time, days int) []MailStatsTrendPoint {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
points := make([]MailStatsTrendPoint, 0, days)
for i := days - 1; i >= 0; i-- {
points = append(points, MailStatsTrendPoint{Date: today.AddDate(0, 0, -i).Format("2006-01-02")})
}
return points
}
func mailStatsDistribution(rows []MailStatsFolderCount, attachmentMessages, starred int64) []MailStatsDistributionItem {
roles := map[string]int64{}
for _, row := range rows {
roles[strings.ToLower(row.Role)] += row.Count
}
return []MailStatsDistributionItem{
{Key: "inbox", Label: "收件箱", Count: roles["inbox"]},
{Key: "archive", Label: "已归档", Count: roles["archive"]},
{Key: "spam", Label: "垃圾邮件", Count: roles["spam"]},
{Key: "trash", Label: "已删除", Count: roles["trash"]},
{Key: "attachments", Label: "有附件", Count: attachmentMessages},
{Key: "starred", Label: "已加旗标", Count: starred},
}
}
func (a *App) loadMailStatsTrend(ctx context.Context, where string, args []any, days int, trend []MailStatsTrendPoint) error {
start := ""
if len(trend) > 0 {
start = trend[0].Date + "T00:00:00Z"
}
queryArgs := append(append([]any{}, args...), start)
rows, err := a.db.QueryContext(ctx, `SELECT substr(CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END, 1, 10),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 0 ELSE 1 END),0),
COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0)
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE `+where+` AND f.role<>'drafts' AND (CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END)>=?
GROUP BY 1`, queryArgs...)
if err != nil {
return err
}
defer rows.Close()
byDate := map[string]*MailStatsTrendPoint{}
for i := range trend {
byDate[trend[i].Date] = &trend[i]
}
for rows.Next() {
var date string
var incoming, outgoing int64
if err := rows.Scan(&date, &incoming, &outgoing); err != nil {
return err
}
if point := byDate[date]; point != nil {
point.Incoming = incoming
point.Outgoing = outgoing
}
}
return rows.Err()
}
func (a *App) mailStatsTopContacts(ctx context.Context, where string, args []any) ([]MailStatsContact, error) {
rows, err := a.db.QueryContext(ctx, `SELECT f.role,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
WHERE `+where+`
ORDER BY m.received_at DESC LIMIT 2000`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
counts := map[string]int64{}
for rows.Next() {
var role, from, toJSON, ccJSON, bccJSON string
if err := rows.Scan(&role, &from, &toJSON, &ccJSON, &bccJSON); err != nil {
return nil, err
}
if strings.EqualFold(role, "sent") {
for _, email := range append(append(mailStatsEmailList(toJSON), mailStatsEmailList(ccJSON)...), mailStatsEmailList(bccJSON)...) {
if email != "" {
counts[email]++
}
}
continue
}
if email := normalizeEmail(from); email != "" && strings.Contains(email, "@") {
counts[email]++
}
}
if err := rows.Err(); err != nil {
return nil, err
}
items := make([]MailStatsContact, 0, len(counts))
for email, count := range counts {
items = append(items, MailStatsContact{Email: email, Count: count})
}
sort.Slice(items, func(i, j int) bool {
if items[i].Count == items[j].Count {
return items[i].Email < items[j].Email
}
return items[i].Count > items[j].Count
})
if len(items) > 10 {
items = items[:10]
}
return items, nil
}
func mailStatsEmailList(raw string) []string {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
email := normalizeEmail(value)
if email == "" || !strings.Contains(email, "@") || seen[email] {
continue
}
seen[email] = true
out = append(out, email)
}
return out
}
func (a *App) handleMailCleanup(w http.ResponseWriter, r *http.Request) { func (a *App) handleMailCleanup(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
MailboxID string `json:"mailboxId"` MailboxID string `json:"mailboxId"`
+35 -9
View File
@@ -237,15 +237,24 @@ type BlockedSender struct {
} }
type MailStats struct { type MailStats struct {
TotalMessages int64 `json:"totalMessages"` TotalMessages int64 `json:"totalMessages"`
UnreadMessages int64 `json:"unreadMessages"` TotalIncoming int64 `json:"totalIncoming"`
StarredMessages int64 `json:"starredMessages"` TotalOutgoing int64 `json:"totalOutgoing"`
AttachmentCount int64 `json:"attachmentCount"` UnreadMessages int64 `json:"unreadMessages"`
AttachmentBytes int64 `json:"attachmentBytes"` TodayOutgoing int64 `json:"todayOutgoing"`
StorageBytes int64 `json:"storageBytes"` DraftMessages int64 `json:"draftMessages"`
QuotaBytes int64 `json:"quotaBytes"` FailedSends int64 `json:"failedSends"`
QuotaUsedPct float64 `json:"quotaUsedPct"` StarredMessages int64 `json:"starredMessages"`
ByFolder []MailStatsFolderCount `json:"byFolder"` AttachmentCount int64 `json:"attachmentCount"`
AttachmentBytes int64 `json:"attachmentBytes"`
StorageBytes int64 `json:"storageBytes"`
QuotaBytes int64 `json:"quotaBytes"`
QuotaUsedPct float64 `json:"quotaUsedPct"`
AverageMessageBytes int64 `json:"averageMessageBytes"`
ByFolder []MailStatsFolderCount `json:"byFolder"`
Trend []MailStatsTrendPoint `json:"trend"`
Distribution []MailStatsDistributionItem `json:"distribution"`
TopContacts []MailStatsContact `json:"topContacts"`
} }
type MailStatsFolderCount struct { type MailStatsFolderCount struct {
@@ -256,6 +265,23 @@ type MailStatsFolderCount struct {
Bytes int64 `json:"bytes"` Bytes int64 `json:"bytes"`
} }
type MailStatsTrendPoint struct {
Date string `json:"date"`
Incoming int64 `json:"incoming"`
Outgoing int64 `json:"outgoing"`
}
type MailStatsDistributionItem struct {
Key string `json:"key"`
Label string `json:"label"`
Count int64 `json:"count"`
}
type MailStatsContact struct {
Email string `json:"email"`
Count int64 `json:"count"`
}
type ExternalIMAPAccount struct { type ExternalIMAPAccount struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId,omitempty"` UserID string `json:"userId,omitempty"`
+20 -1
View File
@@ -124,7 +124,26 @@ export type MailRuleCondition = { field?: MailRuleConditionField; operator?: Mai
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; value?: string; labelId?: string } export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; value?: string; labelId?: string }
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; enabled: boolean; createdAt: string; appliedExistingCount?: number } export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string } export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] } export type MailStats = {
totalMessages: number
totalIncoming: number
totalOutgoing: number
unreadMessages: number
todayOutgoing: number
draftMessages: number
failedSends: number
starredMessages: number
attachmentCount: number
attachmentBytes: number
storageBytes: number
quotaBytes: number
quotaUsedPct: number
averageMessageBytes: number
byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[]
trend: { date: string; incoming: number; outgoing: number }[]
distribution: { key: string; label: string; count: number }[]
topContacts: { email: string; count: number }[]
}
export type ForwardingVerifiedEmail = { export type ForwardingVerifiedEmail = {
id: string id: string
email: string email: string
+7 -1
View File
@@ -99,7 +99,13 @@ export const api = {
blockedSenders: () => request<ListResponse<BlockedSender>>("/api/me/blocked-senders"), blockedSenders: () => request<ListResponse<BlockedSender>>("/api/me/blocked-senders"),
createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request<BlockedSender>("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }), createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request<BlockedSender>("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }),
deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }), deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }),
mailStats: (mailboxId?: string) => request<MailStats>(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), mailStats: (mailboxId?: string, days?: number) => {
const query = new URLSearchParams()
if (mailboxId) query.set("mailboxId", mailboxId)
if (days) query.set("days", String(days))
const suffix = query.toString()
return request<MailStats>(`/api/me/stats${suffix ? `?${suffix}` : ""}`)
},
cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }), cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }),
mailboxApplyOptions: () => request<MailboxApplyOptions>("/api/me/mailbox-apply-options"), mailboxApplyOptions: () => request<MailboxApplyOptions>("/api/me/mailbox-apply-options"),
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }), applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
+188 -24
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate, useSearchParams } from "react-router-dom" import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, BookOpen, ChevronDown, Clock3, Code2, Contact, Copy, ExternalLink, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { Archive, ArrowLeft, BarChart3, Ban, BookOpen, ChevronDown, Clock3, Code2, Contact, Copy, ExternalLink, HardDrive, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Star, Sun, Trash2, Users, X } from "lucide-react"
import { QRCodeSVG } from "qrcode.react" import { QRCodeSVG } from "qrcode.react"
import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils" import { cn, formatBytes } from "@/lib/utils"
@@ -62,6 +62,7 @@ export function ProfilePage() {
const passwordFormRef = React.useRef<HTMLFormElement>(null) const passwordFormRef = React.useRef<HTMLFormElement>(null)
const twoFactorFormRef = React.useRef<HTMLFormElement>(null) const twoFactorFormRef = React.useRef<HTMLFormElement>(null)
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
const [statsRangeDays, setStatsRangeDays] = React.useState(30)
const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [displayMode, setDisplayMode] = useDisplayMode() const [displayMode, setDisplayMode] = useDisplayMode()
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all") const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
@@ -120,7 +121,7 @@ export function ProfilePage() {
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) 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 externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels || canManageRules) }) 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 stats = useQuery({ queryKey: ["mail-stats", activeMailboxId, statsRangeDays], queryFn: () => api.mailStats(activeMailboxId, statsRangeDays), enabled: !!activeMailboxId && canViewStats })
const profile = useMutation({ const profile = useMutation({
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }), mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
@@ -454,7 +455,7 @@ export function ProfilePage() {
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} /> if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} /> if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} onRefresh={() => stats.refetch()} />
if (tab === "feedback") return <FeedbackSection /> if (tab === "feedback") return <FeedbackSection />
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} /> if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
return null return null
@@ -2971,27 +2972,41 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
) )
} }
function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; onRefresh: () => void }) { function StatsSection({ stats, mailbox, rangeDays, onRangeChange, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) {
const [range, setRange] = React.useState("30")
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0) const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
const quotaPct = Math.min(stats?.quotaUsedPct || 0, 100) const quotaPct = Math.min(stats?.quotaUsedPct || 0, 100)
const primaryCards = [
{ label: "总收件", value: stats?.totalIncoming || 0, icon: <Mail className="h-4 w-4" />, tone: "bg-blue-50 text-blue-600" },
{ label: "总发件", value: stats?.totalOutgoing || 0, icon: <SendHorizontal className="h-4 w-4" />, tone: "bg-emerald-50 text-emerald-600" },
{ label: "未读邮件", value: stats?.unreadMessages || 0, icon: <MailCheck className="h-4 w-4" />, tone: "bg-amber-50 text-amber-600" },
{ label: "存储用量", value: quotaLabel, detail: stats?.quotaBytes ? `${quotaPct.toFixed(0)}%` : "不限", icon: <HardDrive className="h-4 w-4" />, tone: "bg-slate-100 text-slate-700" },
]
const secondaryStats = [
{ label: "今日发件", value: stats?.todayOutgoing || 0 },
{ label: "草稿", value: stats?.draftMessages || 0 },
{ label: "发送失败", value: stats?.failedSends || 0 },
{ label: "平均邮件大小", value: formatBytes(stats?.averageMessageBytes || 0) },
]
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs font-medium text-muted-foreground">{mailbox?.address || "未选择邮箱"}</div> <div className="min-w-0">
<p className="text-sm leading-6 text-muted-foreground"></p>
{mailbox && <p className="mt-0.5 truncate text-xs text-muted-foreground/80">{mailbox.address}</p>}
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center"> <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<div className="grid grid-cols-4 rounded-md border bg-background p-0.5 sm:flex"> <div className="grid grid-cols-4 rounded-md border bg-background p-0.5 sm:flex">
{[ {[
["7", "7天"], [7, "7天"],
["30", "30天"], [30, "30天"],
["90", "90天"], [90, "90天"],
["365", "365天"], [365, "365天"],
].map(([value, label]) => ( ].map(([value, label]) => (
<button <button
key={value} key={value}
type="button" type="button"
className={cn("h-7 rounded px-2.5 text-xs font-medium transition-colors", range === value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground")} className={cn("h-7 rounded px-2.5 text-xs font-medium transition-colors", rangeDays === value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground")}
onClick={() => setRange(value)} onClick={() => onRangeChange(Number(value))}
> >
{label} {label}
</button> </button>
@@ -3000,22 +3015,171 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={onRefresh}><RefreshCcw className="h-4 w-4" /></Button> <Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={onRefresh}><RefreshCcw className="h-4 w-4" /></Button>
</div> </div>
</div> </div>
<StatsSummary stats={stats} /> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(320px,0.75fr)]"> {primaryCards.map((card) => (
<SettingsCard title="文件夹分布" contentClassName="space-y-3"> <div key={card.label} className="rounded-lg border bg-card p-4 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<FolderDistribution stats={stats} /> <div className="mb-3 flex items-center justify-between gap-3">
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
{card.detail && <Badge variant="secondary" className="rounded-md font-normal">{card.detail}</Badge>}
</div>
<div className="truncate text-[22px] font-semibold leading-7 text-foreground">{card.value}</div>
<div className="mt-1 text-xs text-muted-foreground">{card.label}</div>
</div>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{secondaryStats.map((item) => (
<div key={item.label} className="rounded-lg border bg-background px-4 py-3">
<div className="text-lg font-semibold leading-6 text-foreground">{item.value}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.label}</div>
</div>
))}
</div>
<SettingsCard title="收发趋势" contentClassName="pt-1">
<StatsTrendChart points={stats?.trend || []} />
</SettingsCard>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)]">
<SettingsCard title="邮件分布" contentClassName="pt-1">
<StatsDistribution items={stats?.distribution || []} />
</SettingsCard> </SettingsCard>
<SettingsCard title="存储用量"> <SettingsCard title="存储用量">
<div className="mb-3 flex items-end justify-between gap-3"> <StatsStorage quotaLabel={quotaLabel} quotaPct={quotaPct} hasQuota={!!stats?.quotaBytes} />
<div className="text-lg font-semibold text-foreground">{quotaLabel}</div>
<div className="text-sm font-semibold text-foreground">{stats?.quotaBytes ? `${quotaPct.toFixed(0)}%` : "不限"}</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${stats?.quotaBytes ? quotaPct : 12}%` }} />
</div>
<p className="mt-3 text-xs text-muted-foreground">{quotaPct >= 90 ? "存储容量接近上限,请及时清理。" : "存储容量使用正常。"}</p>
</SettingsCard> </SettingsCard>
</div> </div>
<SettingsCard title="常用联系人" contentClassName="pt-1">
<StatsContacts contacts={stats?.topContacts || []} />
</SettingsCard>
</div>
)
}
function StatsTrendChart({ points }: { points: MailStats["trend"] }) {
const data = points.length ? points : [{ date: "", incoming: 0, outgoing: 0 }]
const maxValue = Math.max(...data.flatMap((item) => [item.incoming, item.outgoing]), 1)
const width = 520
const height = 210
const padding = { top: 18, right: 14, bottom: 32, left: 34 }
const plotWidth = width - padding.left - padding.right
const plotHeight = height - padding.top - padding.bottom
const xFor = (index: number) => padding.left + (data.length === 1 ? plotWidth / 2 : (index / (data.length - 1)) * plotWidth)
const yFor = (value: number) => padding.top + plotHeight - (value / maxValue) * plotHeight
const pathFor = (key: "incoming" | "outgoing") => data.map((item, index) => `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(1)} ${yFor(item[key]).toFixed(1)}`).join(" ")
const ticks = trendTicks(data)
return (
<div className="rounded-lg border bg-background p-3">
<div className="mb-3 flex items-center gap-4 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-blue-500" /></span>
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-emerald-500" /></span>
</div>
<svg viewBox={`0 0 ${width} ${height}`} className="h-[220px] w-full overflow-visible" role="img" aria-label="邮件收发趋势">
{[0, 0.25, 0.5, 0.75, 1].map((step) => {
const y = padding.top + plotHeight * step
return <line key={step} x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-border" strokeDasharray={step === 1 ? undefined : "3 5"} />
})}
<path d={pathFor("incoming")} fill="none" className="stroke-blue-500" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<path d={pathFor("outgoing")} fill="none" className="stroke-emerald-500" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
{data.map((item, index) => (
<g key={`${item.date}-${index}`}>
<circle cx={xFor(index)} cy={yFor(item.incoming)} r="2.8" className="fill-blue-500" />
<circle cx={xFor(index)} cy={yFor(item.outgoing)} r="2.8" className="fill-emerald-500" />
</g>
))}
<text x="0" y={padding.top + 4} className="fill-muted-foreground text-[11px]">{maxValue}</text>
<text x="0" y={padding.top + plotHeight + 4} className="fill-muted-foreground text-[11px]">0</text>
{ticks.map((tick) => (
<text key={`${tick.index}-${tick.label}`} x={xFor(tick.index)} y={height - 8} textAnchor="middle" className="fill-muted-foreground text-[11px]">{tick.label}</text>
))}
</svg>
</div>
)
}
function trendTicks(data: MailStats["trend"]) {
if (data.length === 0) return []
const count = Math.min(5, data.length)
const seen = new Set<number>()
const ticks: { index: number; label: string }[] = []
for (let i = 0; i < count; i++) {
const index = count === 1 ? 0 : Math.round((i / (count - 1)) * (data.length - 1))
if (seen.has(index)) continue
seen.add(index)
ticks.push({ index, label: formatStatsDate(data[index]?.date || "") })
}
return ticks
}
function formatStatsDate(value: string) {
if (!value) return ""
const [, month, day] = value.split("-")
return month && day ? `${month}-${day}` : value
}
function StatsDistribution({ items }: { items: MailStats["distribution"] }) {
const rows = items.length ? items : [
{ key: "inbox", label: "收件箱", count: 0 },
{ key: "archive", label: "已归档", count: 0 },
{ key: "spam", label: "垃圾邮件", count: 0 },
{ key: "trash", label: "已删除", count: 0 },
{ key: "attachments", label: "有附件", count: 0 },
{ key: "starred", label: "已加旗标", count: 0 },
]
const maxCount = Math.max(...rows.map((row) => row.count), 1)
return (
<div className="grid gap-2 sm:grid-cols-2">
{rows.map((row) => (
<div key={row.key} className="rounded-lg border bg-background p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 items-center gap-2 font-medium text-foreground">
<span className="text-muted-foreground">{distributionIcon(row.key)}</span>
<span className="truncate">{row.label}</span>
</div>
<span className="shrink-0 font-semibold">{row.count}</span>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary/80" style={{ width: `${Math.max(5, Math.round((row.count / maxCount) * 100))}%` }} />
</div>
</div>
))}
</div>
)
}
function distributionIcon(key: string) {
const cls = "h-4 w-4"
if (key === "archive") return <Archive className={cls} />
if (key === "spam") return <MailX className={cls} />
if (key === "trash") return <Trash2 className={cls} />
if (key === "attachments") return <Image className={cls} />
if (key === "starred") return <Star className={cls} />
return <Mail className={cls} />
}
function StatsStorage({ quotaLabel, quotaPct, hasQuota }: { quotaLabel: string; quotaPct: number; hasQuota: boolean }) {
return (
<div>
<div className="mb-3 flex items-end justify-between gap-3">
<div className="text-lg font-semibold text-foreground">{quotaLabel}</div>
<div className="text-sm font-semibold text-foreground">{hasQuota ? `${quotaPct.toFixed(0)}%` : "不限"}</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${hasQuota ? quotaPct : 12}%` }} />
</div>
<p className="mt-3 text-xs text-muted-foreground">{quotaPct >= 90 ? "存储容量接近上限,请及时清理。" : "存储容量使用正常。"}</p>
</div>
)
}
function StatsContacts({ contacts }: { contacts: MailStats["topContacts"] }) {
if (contacts.length === 0) return <EmptyState icon={<Users />} text="暂无常用联系人" description="有邮件往来后会显示联系人排行" />
return (
<div className="divide-y rounded-lg border bg-background">
{contacts.map((item, index) => (
<div key={item.email} className="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 px-4 py-3 text-sm">
<div className="flex size-7 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">{index + 1}</div>
<div className="min-w-0 truncate font-medium text-foreground">{item.email}</div>
<Badge variant="secondary" className="rounded-md font-normal">{item.count} </Badge>
</div>
))}
</div> </div>
) )
} }