feat(mail): 增强邮件限流与配额配置
- 新增 IMAP/POP3 事件表与清理任务,并接入 Dovecot auth policy。 - 暴露默认权限限额接口,前端权限组页面改为动态读取。 - 为 Dovecot 配置用户配额、连接数限制和 auth policy。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user