From 36b4c4c60f68c8d174d8b90da4eba573521a04fe Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Tue, 23 Jun 2026 11:09:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=9D=83=E9=99=90=E7=BB=84):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=B4=A6=E5=8F=B7=E9=85=8D=E9=A2=9D=E4=B8=8E=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E5=8F=91=E9=80=81=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为权限组与用户新增配额字段,支持附件大小、SMTP/IMAP/POP3 频率限制。 - 后端补充权限组配额校验、迁移与 SMTP 发送限流,邮件发送/定时发送时统一生效。 - 前端在管理页、邮件撰写页和个人资料页展示并编辑配额信息。 --- apps/api/internal/app/app.go | 41 +++ apps/api/internal/app/app_test.go | 85 ++++++ apps/api/internal/app/mail_handlers.go | 117 +++++++- .../internal/app/permission_group_handlers.go | 64 ++++- apps/api/internal/app/permissions.go | 257 ++++++++++++++++-- apps/api/internal/app/types.go | 1 + apps/web/src/lib/api-types.ts | 5 +- apps/web/src/lib/api.ts | 6 +- apps/web/src/pages/admin.tsx | 66 ++++- apps/web/src/pages/mail.tsx | 40 ++- apps/web/src/pages/profile.tsx | 35 ++- 11 files changed, 666 insertions(+), 51 deletions(-) diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 617f91a..5111a79 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -120,6 +120,7 @@ func (a *App) migrate(ctx context.Context) error { name TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', permissions_json TEXT NOT NULL DEFAULT '[]', + limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}', system INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -254,6 +255,13 @@ func (a *App) migrate(ctx context.Context) error { sent_at TEXT )`, `CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(status, send_at)`, + `CREATE TABLE IF NOT EXISTS smtp_send_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_smtp_send_events_user_created ON smtp_send_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, @@ -343,12 +351,45 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil { return err } + if err := a.migratePermissionGroupLimits(ctx); err != nil { + return err + } if err := a.ensureDefaultPermissionGroups(ctx); err != nil { return err } return nil } +func (a *App) migratePermissionGroupLimits(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(permission_groups)`) + if err != nil { + return err + } + hasLimits := false + for rows.Next() { + var cid int + var name, typ string + var notnull int + var dflt any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + rows.Close() + return err + } + if name == "limits_json" { + hasLimits = true + } + } + if err := rows.Close(); err != nil { + return err + } + if hasLimits { + return nil + } + _, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`) + return err +} + // migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed() // that implicitly created an admin mailbox with display_name "LanQin Admin". // Current seed() creates mailboxes with display_name = admin email, so this migration diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index f9bd9ae..043a313 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -183,6 +183,20 @@ func updateRegularPermissionGroup(t *testing.T, admin *testClient, permissions [ return group } +func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, permissions []string, limits PermissionLimits) PermissionGroup { + t.Helper() + var group PermissionGroup + if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{ + "name": "Regular Users", + "description": "Default permissions for regular users", + "permissions": permissions, + "limits": limits, + }, &group); code != http.StatusOK { + t.Fatalf("update regular permission group limits code=%d group=%+v", code, group) + } + return group +} + func systemSettingsPayload(settings SystemSettings) map[string]any { return map[string]any{ "publicHostname": settings.PublicHostname, @@ -380,6 +394,70 @@ func TestScheduleSendQueuesFutureMessage(t *testing.T) { } } +func TestPermissionGroupMailLimits(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("admin login code=%d body=%v", code, login) + } + updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1}) + + domainID := mustDefaultDomainID(t, a) + sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil) + recipient := createTestMailbox(t, admin, domainID, "limited-recipient", "Limited Recipient", "Password123!", nil) + + user := &testClient{t: t, server: ts} + if code := user.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("user login code=%d", code) + } + var me struct { + User User `json:"user"` + } + if code := user.do("GET", "/api/me", nil, &me); code != http.StatusOK { + t.Fatalf("me code=%d user=%+v", code, me.User) + } + if me.User.Limits.MaxAttachmentMB != 1 || me.User.Limits.SMTPMinuteLimit != 1 || me.User.Limits.IMAPMinuteLimit != 1 || me.User.Limits.POP3MinuteLimit != 1 { + t.Fatalf("user limits not attached: %+v", me.User.Limits) + } + + tooLargeAttachment := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("x"), 1024*1024+1)) + var errBody map[string]any + if code := user.do("POST", "/api/mail/send", map[string]any{ + "mailboxId": sender.ID, + "to": []string{recipient.Address}, + "subject": "too large", + "text": "body", + "html": "

body

", + "attachments": []map[string]string{{ + "filename": "large.bin", + "contentType": "application/octet-stream", + "contentBase64": tooLargeAttachment, + }}, + }, &errBody); code != http.StatusBadRequest { + t.Fatalf("oversized attachment should be rejected code=%d body=%v", code, errBody) + } + + var sent MailMessage + payload := map[string]any{ + "mailboxId": sender.ID, + "to": []string{recipient.Address}, + "subject": "first limited send", + "text": "body", + "html": "

body

", + } + if code := user.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated { + t.Fatalf("first send code=%d msg=%+v", code, sent) + } + payload["subject"] = "second limited send" + if code := user.do("POST", "/api/mail/send", payload, &errBody); code != http.StatusTooManyRequests { + t.Fatalf("smtp minute limit should reject second send code=%d body=%v", code, errBody) + } +} + func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) @@ -925,6 +1003,9 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) { t.Fatalf("missing fixed permission group %s in %+v", group.ID, groups.Items) } } + if groupByID[PermissionGroupRegular].Limits != defaultPermissionLimits() { + t.Fatalf("regular group limits=%+v want %+v", groupByID[PermissionGroupRegular].Limits, defaultPermissionLimits()) + } if groups.Items[0].ID != PermissionGroupSuperAdmin || groups.Items[1].ID != PermissionGroupRegular { t.Fatalf("unexpected fixed permission groups: %+v", groups.Items) } @@ -938,9 +1019,13 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) { "name": "Mailbox Viewers", "description": "Can view mailboxes only", "permissions": []string{PermissionAdminOverview, PermissionMailboxesView}, + "limits": PermissionLimits{MaxAttachmentMB: 5, SMTPDailyLimit: 8, SMTPMinuteLimit: 2, IMAPMinuteLimit: 5, POP3MinuteLimit: 3}, }, &customGroup); code != http.StatusCreated { t.Fatalf("custom permission group creation code=%d group=%+v", code, customGroup) } + if customGroup.Limits.MaxAttachmentMB != 5 || customGroup.Limits.SMTPDailyLimit != 8 || customGroup.Limits.SMTPMinuteLimit != 2 || customGroup.Limits.IMAPMinuteLimit != 5 || customGroup.Limits.POP3MinuteLimit != 3 { + t.Fatalf("custom permission group limits=%+v", customGroup.Limits) + } if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) { t.Fatalf("custom permission group permissions=%+v", customGroup) } diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index a7f4966..caddefd 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -391,7 +391,7 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "mailbox not found") return } - msg, err := a.sendMailNow(r.Context(), mb, req) + msg, err := a.sendMailNow(r.Context(), currentUser(r), mb, req) if err != nil { if errors.Is(err, errNoRecipients) { badRequest(w, err) @@ -401,6 +401,14 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { badRequest(w, err) return } + if errors.Is(err, errAttachmentTooLarge) { + badRequest(w, err) + return + } + if errors.Is(err, errSMTPRateLimited) { + respondError(w, http.StatusTooManyRequests, err.Error()) + return + } if strings.HasPrefix(err.Error(), "smtp delivery failed:") { respondError(w, http.StatusBadGateway, err.Error()) return @@ -413,8 +421,13 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { var errNoRecipients = errors.New("at least one recipient is required") var errInvalidMIME = errors.New("invalid mime message") +var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit") +var errSMTPRateLimited = errors.New("smtp send rate limit exceeded") -func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput) (*MailMessage, error) { +func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) { + if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil { + return nil, err + } req.To, req.CC, req.BCC = dedupeEmails(req.To), dedupeEmails(req.CC), dedupeEmails(req.BCC) allRecipients := append(append([]string{}, req.To...), append(req.CC, req.BCC...)...) if len(allRecipients) == 0 { @@ -439,6 +452,9 @@ func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput if err != nil { return nil, fmt.Errorf("%w: %v", errInvalidMIME, err) } + if err := a.recordSMTPRate(ctx, user, mb); err != nil { + return nil, err + } if a.cfg.SMTPHost != "" { if err := a.sendSMTP(mb.Address, allRecipients, mimeBytes); err != nil { return nil, fmt.Errorf("smtp delivery failed: %w", err) @@ -505,6 +521,83 @@ func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput return msg, nil } +func userLimits(user *User) PermissionLimits { + if user == nil { + return defaultPermissionLimits() + } + return user.Limits +} + +func validateAttachmentLimit(attachments []AttachmentInput, limits PermissionLimits) error { + limitBytes := attachmentLimitBytes(limits) + if limitBytes == 0 { + return nil + } + for _, att := range attachments { + decodedLen, err := decodedBase64Len(att.ContentBase64) + if err != nil { + return fmt.Errorf("%w: %v", errInvalidMIME, err) + } + if decodedLen > limitBytes { + return fmt.Errorf("%w: max %d MB", errAttachmentTooLarge, limits.MaxAttachmentMB) + } + } + return nil +} + +func decodedBase64Len(value string) (int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + data, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return 0, err + } + return int64(len(data)), nil +} + +func (a *App) recordSMTPRate(ctx context.Context, user *User, mb *Mailbox) error { + if user == nil || mb == nil || user.Role == "admin" { + return nil + } + limits := user.Limits + if limits.SMTPDailyLimit == 0 && limits.SMTPMinuteLimit == 0 { + return nil + } + now := a.now().UTC() + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + 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 { + return err + } + if count >= limits.SMTPDailyLimit { + return fmt.Errorf("%w: daily limit %d", errSMTPRateLimited, limits.SMTPDailyLimit) + } + } + if limits.SMTPMinuteLimit > 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(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil { + return err + } + if count >= limits.SMTPMinuteLimit { + return fmt.Errorf("%w: per-minute limit %d", errSMTPRateLimited, limits.SMTPMinuteLimit) + } + } + if _, err := tx.ExecContext(ctx, `INSERT INTO smtp_send_events(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)`, newID("smtp"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil { + return err + } + return tx.Commit() +} + func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) { var req mailDraftInput if err := decodeJSON(r, &req); err != nil { @@ -516,6 +609,16 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "mailbox not found") return } + if req.Attachments != nil { + if err := validateAttachmentLimit(*req.Attachments, userLimits(currentUser(r))); err != nil { + if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) { + badRequest(w, err) + return + } + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + } compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML} compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC) subject := strings.TrimSpace(compose.Subject) @@ -687,6 +790,14 @@ func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) { return } compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, Attachments: req.Attachments} + if err := validateAttachmentLimit(compose.Attachments, userLimits(currentUser(r))); err != nil { + if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) { + badRequest(w, err) + return + } + respondError(w, http.StatusInternalServerError, err.Error()) + return + } compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC) if len(append(append([]string{}, compose.To...), append(compose.CC, compose.BCC...)...)) == 0 { badRequest(w, errNoRecipients) @@ -823,7 +934,7 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID, return } compose := mailComposeInput{MailboxID: payload.MailboxID, To: payload.To, CC: payload.CC, BCC: payload.BCC, Subject: payload.Subject, Text: payload.Text, HTML: payload.HTML, Attachments: payload.Attachments} - if _, err := a.sendMailNow(ctx, mb, compose); err != nil { + if _, err := a.sendMailNow(ctx, user, mb, compose); err != nil { a.markScheduledSendFailed(ctx, id, err.Error()) return } diff --git a/apps/api/internal/app/permission_group_handlers.go b/apps/api/internal/app/permission_group_handlers.go index 55f608b..273f880 100644 --- a/apps/api/internal/app/permission_group_handlers.go +++ b/apps/api/internal/app/permission_group_handlers.go @@ -14,7 +14,7 @@ func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) { } func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) { - rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,system,created_at,updated_at + rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at FROM permission_groups ORDER BY created_at ASC,name ASC`) if err != nil { @@ -25,14 +25,15 @@ func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) items := []PermissionGroup{} for rows.Next() { var item PermissionGroup - var raw, created, updated string + var rawPermissions, rawLimits, created, updated string var system int - if err := rows.Scan(&item.ID, &item.Name, &item.Description, &raw, &system, &created, &updated); err != nil { + if err := rows.Scan(&item.ID, &item.Name, &item.Description, &rawPermissions, &rawLimits, &system, &created, &updated); err != nil { rows.Close() respondError(w, http.StatusInternalServerError, "failed to scan permission groups") return } - item.Permissions = decodeStoredPermissions(raw) + item.Permissions = decodeStoredPermissions(rawPermissions) + item.Limits = decodeStoredLimits(rawLimits) item.System = intBool(system) item.CreatedAt = parseTime(created) item.UpdatedAt = parseTime(updated) @@ -83,9 +84,10 @@ func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) { var req struct { - Name string `json:"name"` - Description string `json:"description"` - Permissions []string `json:"permissions"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + Limits *PermissionLimits `json:"limits"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) @@ -105,10 +107,23 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold") return } + limits := defaultPermissionLimits() + if req.Limits != nil { + var err error + limits, err = normalizePermissionLimits(*req.Limits) + if err != nil { + badRequest(w, err) + return + } + } + if !actorCanGrantLimits(currentUser(r), limits) { + respondError(w, http.StatusForbidden, "cannot grant limits above your own") + return + } id := newID("pg") now := a.now().UTC().Format(time.RFC3339Nano) - if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at) - VALUES(?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), now, now); err != nil { + if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at) + VALUES(?,?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), now, now); err != nil { badRequest(w, err) return } @@ -132,9 +147,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request return } var req struct { - Name string `json:"name"` - Description string `json:"description"` - Permissions []string `json:"permissions"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + Limits *PermissionLimits `json:"limits"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) @@ -154,8 +170,28 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold") return } - if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,updated_at=? WHERE id=?`, - name, strings.TrimSpace(req.Description), encodePermissions(permissions), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { + limits := defaultPermissionLimits() + if req.Limits != nil { + var err error + limits, err = normalizePermissionLimits(*req.Limits) + if err != nil { + badRequest(w, err) + return + } + } else { + var rawLimits string + if err := a.db.QueryRowContext(r.Context(), `SELECT limits_json FROM permission_groups WHERE id=?`, id).Scan(&rawLimits); err != nil { + respondError(w, http.StatusNotFound, "permission group not found") + return + } + limits = decodeStoredLimits(rawLimits) + } + if !actorCanGrantLimits(currentUser(r), limits) { + respondError(w, http.StatusForbidden, "cannot grant limits above your own") + return + } + if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,limits_json=?,updated_at=? WHERE id=?`, + name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { badRequest(w, err) return } diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go index 02f35f6..1a9bbba 100644 --- a/apps/api/internal/app/permissions.go +++ b/apps/api/internal/app/permissions.go @@ -125,14 +125,125 @@ type PermissionGroupSummary struct { } type PermissionGroup struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Permissions []string `json:"permissions"` - System bool `json:"system"` - UserCount int `json:"userCount"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + Limits PermissionLimits `json:"limits"` + System bool `json:"system"` + UserCount int `json:"userCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PermissionLimits struct { + MaxAttachmentMB int `json:"maxAttachmentMb"` + SMTPDailyLimit int `json:"smtpDailyLimit"` + SMTPMinuteLimit int `json:"smtpMinuteLimit"` + IMAPMinuteLimit int `json:"imapMinuteLimit"` + POP3MinuteLimit int `json:"pop3MinuteLimit"` +} + +func defaultPermissionLimits() PermissionLimits { + return PermissionLimits{ + MaxAttachmentMB: 25, + SMTPDailyLimit: 200, + SMTPMinuteLimit: 20, + IMAPMinuteLimit: 200, + POP3MinuteLimit: 150, + } +} + +func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error) { + if limits.MaxAttachmentMB < 0 { + return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative") + } + if limits.SMTPDailyLimit < 0 { + return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative") + } + if limits.SMTPMinuteLimit < 0 { + return PermissionLimits{}, errors.New("smtpMinuteLimit cannot be negative") + } + if limits.IMAPMinuteLimit < 0 { + return PermissionLimits{}, errors.New("imapMinuteLimit cannot be negative") + } + if limits.POP3MinuteLimit < 0 { + return PermissionLimits{}, errors.New("pop3MinuteLimit cannot be negative") + } + return limits, nil +} + +func decodeStoredLimits(value string) PermissionLimits { + limits := defaultPermissionLimits() + if strings.TrimSpace(value) == "" { + return limits + } + _ = json.Unmarshal([]byte(value), &limits) + normalized, err := normalizePermissionLimits(limits) + if err != nil { + return defaultPermissionLimits() + } + return normalized +} + +func encodePermissionLimits(limits PermissionLimits) string { + normalized, err := normalizePermissionLimits(limits) + if err != nil { + normalized = defaultPermissionLimits() + } + data, _ := json.Marshal(normalized) + return string(data) +} + +func mergePermissionLimits(left, right PermissionLimits) PermissionLimits { + return PermissionLimits{ + MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB), + SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit), + SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit), + IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit), + POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit), + } +} + +func mergeLimitValue(left, right int) int { + if left == 0 || right == 0 { + return 0 + } + if right > left { + return right + } + return left +} + +func actorCanGrantLimits(actor *User, limits PermissionLimits) bool { + if actor == nil { + return false + } + if actor.Role == "admin" { + return true + } + return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) && + canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) && + canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) && + canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) && + canGrantLimitValue(actor.Limits.POP3MinuteLimit, limits.POP3MinuteLimit) +} + +func canGrantLimitValue(actorLimit, requestedLimit int) bool { + if actorLimit == 0 { + return true + } + if requestedLimit == 0 { + return false + } + return requestedLimit <= actorLimit +} + +func attachmentLimitBytes(limits PermissionLimits) int64 { + if limits.MaxAttachmentMB <= 0 { + return 0 + } + return int64(limits.MaxAttachmentMB) * 1024 * 1024 } var legacyPermissionExpansions = map[string][]string{ @@ -314,6 +425,7 @@ func defaultPermissionGroups() []PermissionGroup { Name: "超级管理员", Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。", Permissions: allPermissionKeys(), + Limits: PermissionLimits{}, System: true, }, { @@ -321,6 +433,7 @@ func defaultPermissionGroups() []PermissionGroup { Name: "普通用户", Description: "仅可使用自己的邮箱功能,不包含后台权限。", Permissions: regularUserDefaultPermissions(), + Limits: defaultPermissionLimits(), System: true, }, } @@ -386,15 +499,15 @@ func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error { if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET name=name || ' (' || id || ')' WHERE name=? AND id<>?`, item.Name, item.ID); err != nil { return err } - query := `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at) - VALUES(?,?,?,?,?,?,?) - ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, system=excluded.system, updated_at=excluded.updated_at` + query := `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, limits_json=excluded.limits_json, system=excluded.system, updated_at=excluded.updated_at` if item.ID == PermissionGroupRegular { - query = `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at) - VALUES(?,?,?,?,?,?,?) + query = `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, system=excluded.system, updated_at=excluded.updated_at` } - if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), boolInt(item.System), now, now); err != nil { + if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), encodePermissionLimits(item.Limits), boolInt(item.System), now, now); err != nil { return err } } @@ -475,11 +588,16 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error { if err != nil { return err } + limits, err := a.limitsForUser(ctx, u.ID, u.Role) + if err != nil { + return err + } groupIDs, groups, err := a.permissionGroupsForUser(ctx, u.ID, u.Role) if err != nil { return err } u.Permissions = permissions + u.Limits = limits u.PermissionGroupIDs = groupIDs u.PermissionGroups = groups u.Protected = a.isDefaultAdminUser(u) @@ -526,6 +644,59 @@ func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]st return out, nil } +func (a *App) limitsForUser(ctx context.Context, userID, role string) (PermissionLimits, error) { + if role == "admin" { + return PermissionLimits{}, nil + } + limits, ok, err := a.regularGroupLimits(ctx, nil) + if err != nil { + return PermissionLimits{}, err + } + if !ok { + limits = defaultPermissionLimits() + } + rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.limits_json + FROM permission_groups pg + JOIN user_permission_groups upg ON upg.group_id=pg.id + WHERE upg.user_id=?`, userID) + if err != nil { + return PermissionLimits{}, err + } + defer rows.Close() + for rows.Next() { + var groupID, raw string + if err := rows.Scan(&groupID, &raw); err != nil { + return PermissionLimits{}, err + } + if !isAssignablePermissionGroupID(groupID) { + continue + } + limits = mergePermissionLimits(limits, decodeStoredLimits(raw)) + } + if err := rows.Err(); err != nil { + return PermissionLimits{}, err + } + return limits, nil +} + +func (a *App) regularGroupLimits(ctx context.Context, tx *sql.Tx) (PermissionLimits, bool, error) { + var raw string + query := `SELECT limits_json FROM permission_groups WHERE id=?` + var err error + if tx != nil { + err = tx.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw) + } else { + err = a.db.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw) + } + if errors.Is(err, sql.ErrNoRows) { + return PermissionLimits{}, false, nil + } + if err != nil { + return PermissionLimits{}, false, err + } + return decodeStoredLimits(raw), true, nil +} + func (a *App) addRegularGroupPermissions(ctx context.Context, tx *sql.Tx, seen map[string]bool) error { var raw string query := `SELECT permissions_json FROM permission_groups WHERE id=?` @@ -568,6 +739,21 @@ func (a *App) effectivePermissionsForUserGroups(ctx context.Context, tx *sql.Tx, return out, nil } +func (a *App) effectiveLimitsForUserGroups(ctx context.Context, tx *sql.Tx, groupIDs []string) (PermissionLimits, error) { + limits, ok, err := a.regularGroupLimits(ctx, tx) + if err != nil { + return PermissionLimits{}, err + } + if !ok { + limits = defaultPermissionLimits() + } + groupLimits, err := a.limitsForGroupIDs(ctx, tx, groupIDs) + if err != nil { + return PermissionLimits{}, err + } + return mergePermissionLimits(limits, groupLimits), nil +} + func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) { if role == "admin" { group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"} @@ -742,6 +928,31 @@ func (a *App) permissionsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs [ return out, nil } +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} + for _, groupID := range cleanIDList(groupIDs) { + if !isAssignablePermissionGroupID(groupID) { + return PermissionLimits{}, fmt.Errorf("permission group not assignable: %s", groupID) + } + var raw string + query := `SELECT limits_json FROM permission_groups WHERE id=?` + var err error + if tx != nil { + err = tx.QueryRowContext(ctx, query, groupID).Scan(&raw) + } else { + err = a.db.QueryRowContext(ctx, query, groupID).Scan(&raw) + } + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return PermissionLimits{}, fmt.Errorf("permission group not found: %s", groupID) + } + return PermissionLimits{}, err + } + limits = mergePermissionLimits(limits, decodeStoredLimits(raw)) + } + return limits, nil +} + func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID string, groupIDs []string, actor *User) error { groupIDs = cleanIDList(groupIDs) for _, groupID := range groupIDs { @@ -756,6 +967,13 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st if !actorCanGrantPermissions(actor, groupPermissions) { return errors.New("cannot assign permissions you do not hold") } + groupLimits, err := a.effectiveLimitsForUserGroups(ctx, tx, groupIDs) + if err != nil { + return err + } + if !actorCanGrantLimits(actor, groupLimits) { + return errors.New("cannot assign limits above your own") + } exec := func(query string, args ...any) error { var err error if tx != nil { @@ -778,18 +996,19 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st } func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGroup, error) { - row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id) + row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.limits_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id) FROM permission_groups pg LEFT JOIN user_permission_groups upg ON upg.group_id=pg.id WHERE pg.id=? - GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at`, id) + GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.limits_json,pg.system,pg.created_at,pg.updated_at`, id) var group PermissionGroup - var raw, created, updated string + var rawPermissions, rawLimits, created, updated string var system int - if err := row.Scan(&group.ID, &group.Name, &group.Description, &raw, &system, &created, &updated, &group.UserCount); err != nil { + if err := row.Scan(&group.ID, &group.Name, &group.Description, &rawPermissions, &rawLimits, &system, &created, &updated, &group.UserCount); err != nil { return nil, err } - group.Permissions = decodeStoredPermissions(raw) + group.Permissions = decodeStoredPermissions(rawPermissions) + group.Limits = decodeStoredLimits(rawLimits) group.System = intBool(system) group.CreatedAt = parseTime(created) group.UpdatedAt = parseTime(updated) diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 41b7b61..bef6efd 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -11,6 +11,7 @@ type User struct { Protected bool `json:"protected"` TwoFactorEnabled bool `json:"twoFactorEnabled"` Permissions []string `json:"permissions"` + Limits PermissionLimits `json:"limits"` PermissionGroupIDs []string `json:"permissionGroupIds"` PermissionGroups []PermissionGroupSummary `json:"permissionGroups"` CreatedAt time.Time `json:"createdAt"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 1344502..071ca1d 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -47,9 +47,10 @@ export type PermissionKey = | "admin.templates.update" | "admin.templates.reset" export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string } +export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number } export type PermissionGroupSummary = { id: string; name: string } -export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; system: boolean; userCount: number; createdAt: string; updatedAt: string } -export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string } +export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string } +export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string } export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] } export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number } export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 15a6698..8514f67 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 } 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, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -64,8 +64,8 @@ export const api = { adminOverview: () => request("/api/admin/overview"), users: () => request>("/api/admin/users"), permissionGroups: () => request & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"), - createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[] }) => request("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }), - updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[] }) => request(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }), + 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) }), 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 edca358..08d68e6 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -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, 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, SystemSettings } from "@/lib/api" +import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, 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" @@ -52,6 +52,7 @@ const sectionPermissions: Record = { const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email" const projectTag = import.meta.env.VITE_APP_VERSION || "" const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "") +const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 } export function AdminPage() { const me = useMe() @@ -349,6 +350,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[ } + ))} @@ -367,8 +369,12 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: const dialogOpen = open ?? internalOpen const setDialogOpen = onOpenChange ?? setInternalOpen const [permissions, setPermissions] = React.useState(group?.permissions || []) + const [limits, setLimits] = React.useState(group?.limits || defaultPermissionLimits) React.useEffect(() => { - if (dialogOpen) setPermissions(group?.permissions || []) + if (dialogOpen) { + setPermissions(group?.permissions || []) + setLimits(group?.limits || defaultPermissionLimits) + } }, [dialogOpen, group]) const mutation = useMutation({ mutationFn: (form: FormData) => { @@ -376,6 +382,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: name: String(form.get("name") || ""), description: String(form.get("description") || ""), permissions, + limits, } return group ? api.updatePermissionGroup(group.id, payload) : api.createPermissionGroup(payload) }, @@ -401,6 +408,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?: + @@ -455,6 +463,43 @@ function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInf ) } +function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; onChange: (value: PermissionLimits) => void }) { + function update(key: keyof PermissionLimits, raw: string) { + const next = Number(raw) + onChange({ ...value, [key]: Number.isFinite(next) && next > 0 ? Math.floor(next) : 0 }) + } + return ( +
+
+ + 填 0 表示不限制 +
+
+
+ + update("maxAttachmentMb", event.target.value)} /> +
+
+ + update("smtpDailyLimit", event.target.value)} /> +
+
+ + update("smtpMinuteLimit", event.target.value)} /> +
+
+ + update("imapMinuteLimit", event.target.value)} /> +
+
+ + update("pop3MinuteLimit", event.target.value)} /> +
+
+
+ ) +} + function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) { const labelByKey = new Map(catalog.map((item) => [item.key, item.label])) if (permissions.length === 0) return
无后台权限
@@ -468,6 +513,23 @@ function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey ) } +function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) { + const value = limits || defaultPermissionLimits + return ( +
+ 附件 {limitText(value.maxAttachmentMb, "MB")} + SMTP 每日 {limitText(value.smtpDailyLimit, "封")} + SMTP 每分钟 {limitText(value.smtpMinuteLimit, "封")} + IMAP 每分钟 {limitText(value.imapMinuteLimit, "次")} + POP3 每分钟 {limitText(value.pop3MinuteLimit, "次")} +
+ ) +} + +function limitText(value: number, unit: string) { + return value > 0 ? `${value} ${unit}` : "不限" +} + function groupPermissionCatalog(catalog: PermissionInfo[]) { const order: string[] = [] const grouped = new Map() diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 8c38ca6..13f86f8 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -13,7 +13,7 @@ import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap import { useNavigate } from "react-router-dom" import type { ImperativePanelHandle } from "react-resizable-panels" import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" -import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api" +import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, PermissionLimits } from "@/lib/api" import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" @@ -954,7 +954,7 @@ export function MailPage() { )} - { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} /> + { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} /> void; onSent: () => void }) { +function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; limits?: PermissionLimits; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) { const { toast } = useToast() const qc = useQueryClient() const [files, setFiles] = React.useState([]) @@ -1841,6 +1841,8 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "") const [body, setBody] = React.useState(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText)) const activeMailboxId = draft?.mailboxId || mailbox?.id || "" + const maxAttachmentBytes = attachmentLimitBytes(limits) + const maxAttachmentText = maxAttachmentBytes > 0 ? formatBytes(maxAttachmentBytes) : "不限" const composePayload = React.useMemo(() => ({ mailboxId: activeMailboxId, to: splitEmails(toValue), @@ -1975,9 +1977,30 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche }) } + function addFiles(nextFiles: File[]) { + if (nextFiles.length === 0) return + const allowed = maxAttachmentBytes > 0 ? nextFiles.filter((file) => file.size <= maxAttachmentBytes) : nextFiles + const blockedCount = nextFiles.length - allowed.length + if (blockedCount > 0) { + toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` }) + } + if (allowed.length > 0) { + setAttachmentsTouched(true) + setFiles((current) => [...current, ...allowed]) + } + } + + function attachmentsWithinLimit() { + if (maxAttachmentBytes <= 0) return true + if (files.every((file) => file.size <= maxAttachmentBytes)) return true + toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` }) + return false + } + async function prepareSend() { if (!canSend) return if (!mailbox) return + if (!attachmentsWithinLimit()) return const attachments = await Promise.all(files.map(fileToAttachment)) const to = splitEmails(toValue) const cc = showCc ? splitEmails(ccValue) : [] @@ -2015,6 +2038,7 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche toast({ title: "请选择发件邮箱" }) return } + if (!attachmentsWithinLimit()) return const attachments = await Promise.all(files.map(fileToAttachment)) const payload: SendPayload & { draftId?: string; sendAt: string } = { mailboxId: mailbox.id, @@ -2093,8 +2117,9 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche defaultHtml={draft?.html} files={files} signatureText={signatureText} + maxAttachmentText={maxAttachmentText} onChange={setBody} - onPickFiles={(nextFiles) => { setAttachmentsTouched(true); setFiles((current) => [...current, ...nextFiles]) }} + onPickFiles={addFiles} onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }} /> @@ -2328,7 +2353,7 @@ function scheduleToNodeAttributes(schedule: ScheduleDraft) { } } -function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) { +function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, maxAttachmentText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; maxAttachmentText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) { const fileInputRef = React.useRef(null) const dirtyRef = React.useRef(false) const lastDefaultRef = React.useRef(`${defaultValue}\n${defaultHtml || ""}`) @@ -2507,6 +2532,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC editor?.chain().focus().setHorizontalRule().run()}>分隔线 + 附件 {maxAttachmentText} } onClick={() => setScheduleOpen(true)} /> @@ -3065,6 +3091,10 @@ function quoteMessage(message: MailMessage) { return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}` } function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" } +function attachmentLimitBytes(limits?: PermissionLimits) { + const mb = limits?.maxAttachmentMb || 0 + return mb > 0 ? mb * 1024 * 1024 : 0 +} async function fileToAttachment(file: File) { const buffer = await file.arrayBuffer() let binary = "" diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 7eea431..dd5fbca 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels" import { useNavigate, useSearchParams } from "react-router-dom" import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" -import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats } from "@/lib/api" +import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { DisplayMode, useDisplayMode } from "@/lib/display-mode" @@ -303,9 +303,27 @@ export function ProfilePage() { } } -function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) { +function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) { return (
+ + + + 账号配额 + + +
+ + + + + +
+
+
+ + {showStats && } + 账户信息 @@ -451,7 +469,18 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, show - {showStats && } +
+ ) +} + +function LimitBadge({ label, value, unit }: { label: string; value?: number; unit: string }) { + return ( +
+
{label}
+
+ {value !== undefined && value > 0 ? value : "不限"} +
+ {value !== undefined && value > 0 &&
{unit}
}
) }