From 23b04bd343bfbabf2f1ff6fd2de892abe8a067c3 Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Tue, 23 Jun 2026 11:25:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E5=A2=9E=E5=BC=BA=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E9=99=90=E6=B5=81=E4=B8=8E=E9=85=8D=E9=A2=9D=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 IMAP/POP3 事件表与清理任务,并接入 Dovecot auth policy。 - 暴露默认权限限额接口,前端权限组页面改为动态读取。 - 为 Dovecot 配置用户配额、连接数限制和 auth policy。 --- apps/api/internal/app/app.go | 16 +++ apps/api/internal/app/mail_handlers.go | 119 +++++++++++++++++- .../internal/app/permission_group_handlers.go | 4 + apps/api/internal/app/permissions.go | 15 ++- apps/api/internal/app/router_auth.go | 2 + apps/web/src/lib/api.ts | 1 + apps/web/src/pages/admin.tsx | 5 +- deploy/dovecot/dovecot-sql.conf.ext | 2 +- deploy/dovecot/dovecot.conf | 22 ++++ 9 files changed, 179 insertions(+), 7 deletions(-) diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 5111a79..afa1ce0 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -74,6 +74,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { if strings.TrimSpace(cfg.MaildirRoot) != "" { go a.maildirWorker(workerCtx) } + go a.smtpEventsCleanupWorker(workerCtx) return a, nil } @@ -262,6 +263,21 @@ func (a *App) migrate(ctx context.Context) error { created_at TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_smtp_send_events_user_created ON smtp_send_events(user_id, created_at)`, + `CREATE TABLE IF NOT EXISTS imap_events ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_imap_events_user_created ON imap_events(user_id, created_at)`, + `CREATE TABLE IF NOT EXISTS pop3_events ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`, + `CREATE TABLE IF NOT EXISTS contacts ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index caddefd..05ed5e2 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -18,6 +18,9 @@ import ( "github.com/go-chi/chi/v5" ) +// mailMessagesPageSize is the max number of messages returned per page in mail listing. +const mailMessagesPageSize = 30 + type AttachmentInput struct { Filename string `json:"filename"` ContentType string `json:"contentType"` @@ -143,7 +146,7 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe if offset < 0 { offset = 0 } - limit := 30 + 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 ?)` @@ -557,6 +560,90 @@ func decodedBase64Len(value string) (int64, error) { return int64(len(data)), nil } +var errIMAPRateLimited = errors.New("imap rate limit exceeded") +var errPOP3RateLimited = errors.New("pop3 rate limit exceeded") + +func (a *App) checkAndRecordProtocolRate(ctx context.Context, user *User, mb *Mailbox, table string, dailyLimit, minuteLimit int) error { + if dailyLimit == 0 && minuteLimit == 0 { + return nil + } + now := a.now().UTC() + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if dailyLimit > 0 { + var count int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE user_id=? AND created_at>=?", user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil { + return err + } + if count >= dailyLimit { + return fmt.Errorf("daily limit %d", dailyLimit) + } + } + if minuteLimit > 0 { + var count int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE user_id=? AND created_at>=?", user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil { + return err + } + if count >= minuteLimit { + return fmt.Errorf("per-minute limit %d", minuteLimit) + } + } + if _, err := tx.ExecContext(ctx, "INSERT INTO "+table+"(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)", newID("evt"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil { + return err + } + return tx.Commit() +} + +func (a *App) handleAuthPolicy(w http.ResponseWriter, r *http.Request) { + var req struct { + Protocol string `json:"protocol"` + Username string `json:"username"` + IP string `json:"ip"` + } + if err := decodeJSON(r, &req); err != nil { + w.WriteHeader(http.StatusCreated) + respondJSON(w, http.StatusCreated, map[string]string{"status": "allow"}) + return + } + var user *User + if req.Username != "" { + var passHash string + user, passHash, _ = a.userByEmail(r.Context(), req.Username) + _ = passHash + } + if user == nil || user.Disabled { + w.WriteHeader(http.StatusCreated) + respondJSON(w, http.StatusCreated, map[string]string{"status": "deny", "reason": "user not found or disabled"}) + return + } + if user.Role == "admin" { + w.WriteHeader(http.StatusCreated) + respondJSON(w, http.StatusCreated, map[string]string{"status": "allow"}) + return + } + limits := user.Limits + var err error + switch req.Protocol { + case "imap", "IMAP": + if limits.IMAPMinuteLimit > 0 { + err = a.checkAndRecordProtocolRate(r.Context(), user, nil, "imap_events", 0, limits.IMAPMinuteLimit) + } + case "pop3", "POP3": + if limits.POP3MinuteLimit > 0 { + err = a.checkAndRecordProtocolRate(r.Context(), user, nil, "pop3_events", 0, limits.POP3MinuteLimit) + } + } + w.WriteHeader(http.StatusCreated) + if err != nil { + respondJSON(w, http.StatusCreated, map[string]any{"status": "deny", "reason": err.Error()}) + } else { + respondJSON(w, http.StatusCreated, map[string]any{"status": "allow"}) + } +} + func (a *App) recordSMTPRate(ctx context.Context, user *User, mb *Mailbox) error { if user == nil || mb == nil || user.Role == "admin" { return nil @@ -571,9 +658,6 @@ func (a *App) recordSMTPRate(ctx context.Context, user *User, mb *Mailbox) error return err } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `DELETE FROM smtp_send_events WHERE created_at 0 { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil { @@ -881,6 +965,33 @@ func (a *App) scheduledSendWorker(ctx context.Context) { } } +func (a *App) smtpEventsCleanupWorker(ctx context.Context) { + a.log.Info("smtp events cleanup worker started") + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + a.log.Info("smtp events cleanup worker stopped") + return + case <-ticker.C: + a.cleanupStaleEvents(ctx) + } + } +} + +func (a *App) cleanupStaleEvents(ctx context.Context) { + cutoff := a.now().UTC().Add(-24 * time.Hour).Format(time.RFC3339Nano) + for _, table := range []string{"smtp_send_events", "imap_events", "pop3_events"} { + result, err := a.db.ExecContext(ctx, "DELETE FROM "+table+" WHERE created_at 0 { + a.log.Debug("event cleanup deleted rows", "table", table, "count", n) + } + } +} + func (a *App) processDueScheduledSends(ctx context.Context) error { rows, err := a.db.QueryContext(ctx, `SELECT id,mailbox_id,draft_id,payload_json FROM scheduled_sends WHERE status='pending' AND send_at<=? ORDER BY send_at LIMIT 20`, a.now().UTC().Format(time.RFC3339Nano)) if err != nil { diff --git a/apps/api/internal/app/permission_group_handlers.go b/apps/api/internal/app/permission_group_handlers.go index 273f880..5862388 100644 --- a/apps/api/internal/app/permission_group_handlers.go +++ b/apps/api/internal/app/permission_group_handlers.go @@ -13,6 +13,10 @@ func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"items": permissionCatalog()}) } +func (a *App) handleDefaultPermissionLimits(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, defaultPermissionLimits()) +} + func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at FROM permission_groups diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go index 1a9bbba..2c687ee 100644 --- a/apps/api/internal/app/permissions.go +++ b/apps/api/internal/app/permissions.go @@ -215,6 +215,19 @@ func mergeLimitValue(left, right int) int { return left } +func minimalLimits() PermissionLimits { + // minimalLimits sets every field to 1 so that mergePermissionLimits + // (which takes the max of each field) produces correct aggregation + // when no group has a limit set for a given field. + return PermissionLimits{ + MaxAttachmentMB: 1, + SMTPDailyLimit: 1, + SMTPMinuteLimit: 1, + IMAPMinuteLimit: 1, + POP3MinuteLimit: 1, + } +} + func actorCanGrantLimits(actor *User, limits PermissionLimits) bool { if actor == nil { return false @@ -929,7 +942,7 @@ func (a *App) permissionsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs [ } func (a *App) limitsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs []string) (PermissionLimits, error) { - limits := PermissionLimits{MaxAttachmentMB: 1, SMTPDailyLimit: 1, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1} + limits := minimalLimits() for _, groupID := range cleanIDList(groupIDs) { if !isAssignablePermissionGroupID(groupID) { return PermissionLimits{}, fmt.Errorf("permission group not assignable: %s", groupID) diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 9672440..ef12279 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -24,6 +24,7 @@ func (a *App) Router() http.Handler { r.Use(middleware.Recoverer) r.Use(a.corsMiddleware) + r.Post("/auth-policy", a.handleAuthPolicy) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"ok": true, "time": a.now().UTC()}) }) @@ -95,6 +96,7 @@ func (a *App) Router() http.Handler { r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser) r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword) r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser) + r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-limits/defaults", a.handleDefaultPermissionLimits) r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog) r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups) r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 8514f67..db4da83 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -66,6 +66,7 @@ export const api = { permissionGroups: () => request & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"), createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }), updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }), + defaultPermissionLimits: () => request("/api/admin/permission-limits/defaults"), deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 08d68e6..3e0769d 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -368,6 +368,8 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: const [internalOpen, setInternalOpen] = React.useState(false) const dialogOpen = open ?? internalOpen const setDialogOpen = onOpenChange ?? setInternalOpen + const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen }) + const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits const [permissions, setPermissions] = React.useState(group?.permissions || []) const [limits, setLimits] = React.useState(group?.limits || defaultPermissionLimits) React.useEffect(() => { @@ -514,7 +516,8 @@ function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey } function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) { - const value = limits || defaultPermissionLimits + const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits }) + const value = limits || defaultLimitsQuery.data || defaultPermissionLimits return (
附件 {limitText(value.maxAttachmentMb, "MB")} diff --git a/deploy/dovecot/dovecot-sql.conf.ext b/deploy/dovecot/dovecot-sql.conf.ext index e1285d0..cbb90ac 100644 --- a/deploy/dovecot/dovecot-sql.conf.ext +++ b/deploy/dovecot/dovecot-sql.conf.ext @@ -2,4 +2,4 @@ driver = sqlite connect = /data/lanqin.db default_pass_scheme = BLF-CRYPT password_query = SELECT address AS user, password_hash AS password FROM mailboxes WHERE address = '%u' AND status = 'active' -user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active') +user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=' || CAST(m.quota_mb AS TEXT) || 'M' AS quota_rule FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=1024M' AS quota_rule WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active') diff --git a/deploy/dovecot/dovecot.conf b/deploy/dovecot/dovecot.conf index 4f0eab5..3bc9242 100644 --- a/deploy/dovecot/dovecot.conf +++ b/deploy/dovecot/dovecot.conf @@ -10,6 +10,20 @@ ssl_cert =