feat(admin): 增加 Maildir 同步健康状态
- 新增后端健康检查接口与同步追踪,记录运行状态、最近结果、错误摘要和统计信息。 - 前端管理页在存储设置中展示 Maildir 同步健康卡片,并支持手动刷新。 - 补充相关类型定义与测试,覆盖未配置和同步成功两类场景。
This commit is contained in:
@@ -22,12 +22,13 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
@@ -47,7 +48,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy()}
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
|
||||
@@ -2323,6 +2323,103 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaildirSyncHealthDisabled(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var health maildirSyncHealthResponse
|
||||
if code := admin.do("GET", "/api/admin/maildir-sync/health", nil, &health); code != http.StatusOK {
|
||||
t.Fatalf("health code=%d body=%+v", code, health)
|
||||
}
|
||||
if health.Configured || health.Enabled || health.WorkerStarted || health.Running {
|
||||
t.Fatalf("unexpected disabled health: %+v", health)
|
||||
}
|
||||
if health.ScanSeconds != 30 {
|
||||
t.Fatalf("scan seconds=%d, want default 30", health.ScanSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
a.cfg.MaildirRoot = root
|
||||
a.cfg.MaildirScanSeconds = 45
|
||||
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mailboxID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailboxes, err := a.maildirMailboxes(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var admin maildirMailbox
|
||||
for _, mb := range mailboxes {
|
||||
if mb.Address == "admin@lanqin.local" {
|
||||
admin = mb
|
||||
break
|
||||
}
|
||||
}
|
||||
if admin.ID == "" {
|
||||
t.Fatal("admin mailbox not found")
|
||||
}
|
||||
dir := filepath.Join(root, admin.Domain, admin.LocalPart, "Maildir", "new")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := strings.Join([]string{
|
||||
"From: sender@example.test",
|
||||
"To: admin@lanqin.local",
|
||||
"Subject: Maildir health import",
|
||||
"Message-Id: <maildir-health@example.test>",
|
||||
"Date: Sat, 13 Jun 2026 15:00:00 +0000",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"",
|
||||
"hello from health test",
|
||||
}, "\r\n")
|
||||
if err := os.WriteFile(filepath.Join(dir, "1749826800.M1P1.health"), []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
counts, err := a.syncMaildirOnceTracked(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counts.Imported != 1 || counts.FilesScanned != 1 {
|
||||
t.Fatalf("counts=%+v, want imported=1 filesScanned=1", counts)
|
||||
}
|
||||
health := a.maildirHealth.snapshot(a.cfg)
|
||||
if !health.Configured || !health.Enabled {
|
||||
t.Fatalf("configured health=%+v, want enabled", health)
|
||||
}
|
||||
if health.Running {
|
||||
t.Fatalf("health still running: %+v", health)
|
||||
}
|
||||
if health.LastRun == nil || health.LastRun.Status != "success" {
|
||||
t.Fatalf("last run=%+v, want success", health.LastRun)
|
||||
}
|
||||
if health.LastRun.Counts.Imported != 1 || health.Summary.Imported != 1 {
|
||||
t.Fatalf("health counts last=%+v summary=%+v", health.LastRun.Counts, health.Summary)
|
||||
}
|
||||
if health.NextRunAt == nil {
|
||||
t.Fatalf("next run is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaildirSyncImportsSentFolder(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxMaildirRecentErrors = 10
|
||||
|
||||
type maildirSyncCounts struct {
|
||||
FilesScanned int `json:"filesScanned"`
|
||||
Imported int `json:"imported"`
|
||||
Backfilled int `json:"backfilled"`
|
||||
Cleaned int `json:"cleaned"`
|
||||
FileErrors int `json:"fileErrors"`
|
||||
fileErrorDetails []string `json:"-"`
|
||||
}
|
||||
|
||||
func (c maildirSyncCounts) total() int {
|
||||
return c.Imported + c.Backfilled + c.Cleaned
|
||||
}
|
||||
|
||||
type maildirSyncRun struct {
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Counts maildirSyncCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Root string `json:"root"`
|
||||
ScanSeconds int `json:"scanSeconds"`
|
||||
WorkerStarted bool `json:"workerStarted"`
|
||||
Running bool `json:"running"`
|
||||
LastRun *maildirSyncRun `json:"lastRun,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
|
||||
RecentErrors []string `json:"recentErrors"`
|
||||
Summary maildirSyncCounts `json:"summary"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthTracker struct {
|
||||
mu sync.Mutex
|
||||
workerStarted bool
|
||||
running bool
|
||||
current *maildirSyncRun
|
||||
lastRun *maildirSyncRun
|
||||
lastError string
|
||||
nextRunAt *time.Time
|
||||
recentErrors []string
|
||||
summary maildirSyncCounts
|
||||
}
|
||||
|
||||
func newMaildirSyncHealthTracker() *maildirSyncHealthTracker {
|
||||
return &maildirSyncHealthTracker{}
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStarted(nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = true
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStopped() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = false
|
||||
h.nextRunAt = nil
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunStarted(startedAt time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := &maildirSyncRun{StartedAt: startedAt.UTC(), Status: "running"}
|
||||
h.running = true
|
||||
h.current = run
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunFinished(finishedAt time.Time, counts maildirSyncCounts, err error, nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := h.current
|
||||
if run == nil {
|
||||
run = &maildirSyncRun{StartedAt: finishedAt.UTC()}
|
||||
}
|
||||
finished := finishedAt.UTC()
|
||||
run.FinishedAt = &finished
|
||||
run.DurationMs = finished.Sub(run.StartedAt).Milliseconds()
|
||||
run.Counts = counts
|
||||
run.Status = "success"
|
||||
run.Error = ""
|
||||
if err != nil {
|
||||
run.Status = "error"
|
||||
run.Error = err.Error()
|
||||
h.lastError = run.Error
|
||||
h.pushRecentError(run.Error)
|
||||
} else if counts.FileErrors > 0 {
|
||||
run.Status = "partial"
|
||||
if len(counts.fileErrorDetails) > 0 {
|
||||
run.Error = counts.fileErrorDetails[0]
|
||||
h.lastError = run.Error
|
||||
}
|
||||
for _, detail := range counts.fileErrorDetails {
|
||||
h.pushRecentError(detail)
|
||||
}
|
||||
} else {
|
||||
h.lastError = ""
|
||||
}
|
||||
h.summary.FilesScanned += counts.FilesScanned
|
||||
h.summary.Imported += counts.Imported
|
||||
h.summary.Backfilled += counts.Backfilled
|
||||
h.summary.Cleaned += counts.Cleaned
|
||||
h.summary.FileErrors += counts.FileErrors
|
||||
h.running = false
|
||||
h.current = nil
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) snapshot(cfg Config) maildirSyncHealthResponse {
|
||||
root := strings.TrimSpace(cfg.MaildirRoot)
|
||||
scanSeconds := cfg.MaildirScanSeconds
|
||||
if scanSeconds <= 0 {
|
||||
scanSeconds = 30
|
||||
}
|
||||
out := maildirSyncHealthResponse{
|
||||
Configured: root != "",
|
||||
Enabled: root != "",
|
||||
Root: root,
|
||||
ScanSeconds: scanSeconds,
|
||||
}
|
||||
if h == nil {
|
||||
return out
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out.WorkerStarted = h.workerStarted
|
||||
out.Running = h.running
|
||||
out.LastRun = cloneMaildirSyncRun(h.lastRun)
|
||||
out.LastError = h.lastError
|
||||
out.NextRunAt = cloneTimePtr(h.nextRunAt)
|
||||
out.RecentErrors = append([]string(nil), h.recentErrors...)
|
||||
out.Summary = h.summary
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) pushRecentError(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
h.recentErrors = append([]string{value}, h.recentErrors...)
|
||||
if len(h.recentErrors) > maxMaildirRecentErrors {
|
||||
h.recentErrors = h.recentErrors[:maxMaildirRecentErrors]
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMaildirSyncRun(in *maildirSyncRun) *maildirSyncRun {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.FinishedAt = cloneTimePtr(in.FinishedAt)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneTimePtr(in *time.Time) *time.Time {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := in.UTC()
|
||||
return &out
|
||||
}
|
||||
|
||||
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
|
||||
}
|
||||
@@ -49,10 +49,12 @@ func (a *App) maildirWorker(ctx context.Context) {
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
nextRunAt := a.now().UTC()
|
||||
a.maildirHealth.markWorkerStarted(&nextRunAt)
|
||||
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
|
||||
if n, err := a.syncMaildirOnce(ctx); err != nil {
|
||||
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
||||
a.log.Warn("initial maildir sync failed", "error", err)
|
||||
} else if n > 0 {
|
||||
} else if n := counts.total(); n > 0 {
|
||||
a.log.Info("initial maildir sync processed messages", "count", n)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
@@ -60,43 +62,66 @@ func (a *App) maildirWorker(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.maildirHealth.markWorkerStopped()
|
||||
a.log.Info("maildir sync worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := a.syncMaildirOnce(ctx)
|
||||
counts, err := a.syncMaildirOnceTracked(ctx, interval)
|
||||
if err != nil {
|
||||
a.log.Warn("maildir sync failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
if n := counts.total(); n > 0 {
|
||||
a.log.Info("maildir sync processed messages", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
|
||||
startedAt := a.now().UTC()
|
||||
a.maildirHealth.markRunStarted(startedAt)
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
finishedAt := a.now().UTC()
|
||||
var nextRunAt *time.Time
|
||||
if interval > 0 && err == nil {
|
||||
next := finishedAt.Add(interval)
|
||||
nextRunAt = &next
|
||||
}
|
||||
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
|
||||
return counts, err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
return counts.total(), err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
if root == "" {
|
||||
return 0, nil
|
||||
return maildirSyncCounts{}, nil
|
||||
}
|
||||
mailboxes, err := a.maildirMailboxes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return maildirSyncCounts{}, err
|
||||
}
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, mb := range mailboxes {
|
||||
if mb.Unregistered {
|
||||
count, err := a.syncUnregisteredMaildir(ctx, mb)
|
||||
mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
counts.FilesScanned += mbCounts.FilesScanned
|
||||
counts.Imported += mbCounts.Imported
|
||||
counts.FileErrors += mbCounts.FileErrors
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
imported += count
|
||||
continue
|
||||
}
|
||||
folders, err := a.maildirFolders(ctx, mb.ID)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
|
||||
for _, folder := range folders {
|
||||
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(folderBase, sub)
|
||||
@@ -113,20 +138,23 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,15 +162,15 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
}
|
||||
backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
imported += backfilled
|
||||
counts.Backfilled += backfilled
|
||||
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
imported += cleaned
|
||||
return imported, nil
|
||||
counts.Cleaned += cleaned
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
@@ -189,12 +217,17 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
|
||||
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
return counts.Imported, err
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(base, sub)
|
||||
@@ -203,24 +236,27 @@ func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (i
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
|
||||
|
||||
@@ -118,6 +118,7 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
|
||||
@@ -79,6 +79,21 @@ export type BlockedSender = { id: string; mailboxId: string; email: string; reas
|
||||
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
|
||||
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
|
||||
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
|
||||
export type MaildirSyncRun = { startedAt: string; finishedAt?: string; durationMs: number; status: "running" | "success" | "partial" | "error"; error?: string; counts: MaildirSyncCounts }
|
||||
export type MaildirSyncHealth = {
|
||||
configured: boolean
|
||||
enabled: boolean
|
||||
root: string
|
||||
scanSeconds: number
|
||||
workerStarted: boolean
|
||||
running: boolean
|
||||
lastRun?: MaildirSyncRun
|
||||
lastError?: string
|
||||
nextRunAt?: string
|
||||
recentErrors: string[]
|
||||
summary: MaildirSyncCounts
|
||||
}
|
||||
export type SystemSettings = {
|
||||
publicHostname: string
|
||||
publicBaseUrl: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -95,6 +95,7 @@ export const api = {
|
||||
},
|
||||
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
|
||||
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
|
||||
|
||||
@@ -3,7 +3,7 @@ import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -864,6 +864,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
const [openRegistration, setOpenRegistration] = React.useState(false)
|
||||
@@ -912,6 +913,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
|
||||
qc.invalidateQueries({ queryKey: ["admin", "maildir-sync", "health"] })
|
||||
qc.invalidateQueries({ queryKey: ["dns-records"] })
|
||||
qc.invalidateQueries({ queryKey: ["public-settings"] })
|
||||
toast({ title: "系统设置已保存" })
|
||||
@@ -1002,12 +1004,15 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "storage" && <Card>
|
||||
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{settingsTab === "storage" && <div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>存储设置</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Field name="maildirRoot" label="Maildir 根目录" defaultValue={settings?.maildirRoot || ""} required={false} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MaildirSyncHealthCard health={maildirHealth.data} loading={maildirHealth.isLoading} error={maildirHealth.error} onRefresh={() => maildirHealth.refetch()} refreshing={maildirHealth.isFetching} fallbackRoot={settings?.maildirRoot || ""} />
|
||||
</div>}
|
||||
|
||||
{settingsTab === "mail" && <Card>
|
||||
<CardHeader><CardTitle>邮件设置</CardTitle></CardHeader>
|
||||
@@ -1081,6 +1086,102 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
)
|
||||
}
|
||||
|
||||
function MaildirSyncHealthCard({ health, loading, error, onRefresh, refreshing, fallbackRoot }: { health?: MaildirSyncHealth; loading: boolean; error: Error | null; onRefresh: () => void; refreshing: boolean; fallbackRoot: string }) {
|
||||
const root = health?.root || fallbackRoot
|
||||
const configured = health?.configured ?? !!root
|
||||
const lastRun = health?.lastRun
|
||||
const counters = lastRun?.counts || health?.summary
|
||||
const recentErrors = health?.recentErrors || []
|
||||
const status = health?.running ? "running" : lastRun?.status || (configured ? "idle" : "disabled")
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Maildir 同步健康</CardTitle>
|
||||
<div className="break-all text-xs text-muted-foreground">{root || "未配置 Maildir 根目录"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={configured ? "default" : "secondary"}>{configured ? "已配置" : "未配置"}</Badge>
|
||||
<Badge variant={health?.running ? "default" : health?.workerStarted ? "outline" : "secondary"}>{health?.running ? "运行中" : health?.workerStarted ? "worker 已启动" : "worker 未启动"}</Badge>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRefresh} disabled={loading || refreshing}>
|
||||
<RefreshCcw className={cn("mr-2 h-4 w-4", refreshing && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{queryErrorMessage(error)}</div>}
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InfoLine label="当前状态" value={<MaildirStatusBadge status={status} />} />
|
||||
<InfoLine label="最近开始" value={formatOptionalDate(lastRun?.startedAt)} />
|
||||
<InfoLine label="最近结束" value={formatOptionalDate(lastRun?.finishedAt)} />
|
||||
<InfoLine label="最近耗时" value={formatDuration(lastRun?.durationMs)} />
|
||||
<InfoLine label="扫描间隔" value={health?.scanSeconds ? `${health.scanSeconds} 秒` : "-"} />
|
||||
<InfoLine label="下次运行" value={formatOptionalDate(health?.nextRunAt)} />
|
||||
<InfoLine label="最后错误" value={lastRun?.error || health?.lastError || "-"} />
|
||||
<InfoLine label="错误数" value={counterValue(counters, "fileErrors")} />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{maildirCounterRows(counters).map((item) => <InfoBox key={item.key} label={item.label} value={item.value} />)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">最近错误</div>
|
||||
{recentErrors.length === 0 && <Empty text={loading ? "正在读取同步状态..." : "暂无同步错误"} />}
|
||||
{recentErrors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{recentErrors.slice(0, 5).map((item, index) => (
|
||||
<div key={`${item}-${index}`} className="rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="break-words text-destructive">{item || "未知错误"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MaildirStatusBadge({ status }: { status: string }) {
|
||||
const normalized = status.toLowerCase()
|
||||
if (normalized === "running") return <Badge>运行中</Badge>
|
||||
if (["ok", "success", "succeeded", "idle"].includes(normalized)) return <Badge variant="outline">{normalized === "idle" ? "等待下次扫描" : "正常"}</Badge>
|
||||
if (normalized === "partial") return <Badge variant="secondary">部分成功</Badge>
|
||||
if (["error", "failed", "failure"].includes(normalized)) return <Badge variant="destructive">失败</Badge>
|
||||
if (["disabled", "not_configured"].includes(normalized)) return <Badge variant="secondary">未启用</Badge>
|
||||
return <Badge variant="secondary">{status || "-"}</Badge>
|
||||
}
|
||||
|
||||
function maildirCounterRows(counters?: Record<string, number | undefined>) {
|
||||
return [
|
||||
{ key: "filesScanned", label: "扫描文件", value: counterValue(counters, "filesScanned") },
|
||||
{ key: "imported", label: "导入", value: counterValue(counters, "imported") },
|
||||
{ key: "backfilled", label: "回填", value: counterValue(counters, "backfilled") },
|
||||
{ key: "cleaned", label: "清理", value: counterValue(counters, "cleaned") },
|
||||
{ key: "fileErrors", label: "文件错误", value: counterValue(counters, "fileErrors") },
|
||||
]
|
||||
}
|
||||
|
||||
function counterValue(counters: Record<string, number | undefined> | undefined, key: string) {
|
||||
return Number(counters?.[key] || 0)
|
||||
}
|
||||
|
||||
function formatOptionalDate(value?: string) {
|
||||
return value ? formatDate(value) || "-" : "-"
|
||||
}
|
||||
|
||||
function formatDuration(value?: number) {
|
||||
if (!value) return "-"
|
||||
if (value < 1000) return `${value} ms`
|
||||
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)} 秒`
|
||||
}
|
||||
|
||||
function queryErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
|
||||
}
|
||||
|
||||
function parseSemver(tag: string): number[] {
|
||||
return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(Number)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user