feat(mail): 增强邮件限流与配额配置

- 新增 IMAP/POP3 事件表与清理任务,并接入 Dovecot auth policy。
- 暴露默认权限限额接口,前端权限组页面改为动态读取。
- 为 Dovecot 配置用户配额、连接数限制和 auth policy。
This commit is contained in:
LanQin_
2026-06-23 11:25:38 +08:00
parent 36b4c4c60f
commit 23b04bd343
9 changed files with 179 additions and 7 deletions
+16
View File
@@ -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,
+115 -4
View File
@@ -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<?`, now.Add(-24*time.Hour).Format(time.RFC3339Nano)); err != nil {
return err
}
if limits.SMTPDailyLimit > 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<?", cutoff)
if err != nil {
a.log.Warn("event cleanup failed", "table", table, "error", err)
} else if n, _ := result.RowsAffected(); n > 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 {
@@ -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
+14 -1
View File
@@ -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)
+2
View File
@@ -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)
+1
View File
@@ -66,6 +66,7 @@ export const api = {
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request<PermissionLimits>("/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<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
+4 -1
View File
@@ -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<PermissionKey[]>(group?.permissions || [])
const [limits, setLimits] = React.useState<PermissionLimits>(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 (
<div className="mt-3 flex flex-wrap gap-1.5">
<Badge variant="secondary" className="font-normal"> {limitText(value.maxAttachmentMb, "MB")}</Badge>