feat(权限组): 增加账号配额与邮件发送限制

- 为权限组与用户新增配额字段,支持附件大小、SMTP/IMAP/POP3 频率限制。
- 后端补充权限组配额校验、迁移与 SMTP 发送限流,邮件发送/定时发送时统一生效。
- 前端在管理页、邮件撰写页和个人资料页展示并编辑配额信息。
This commit is contained in:
LanQin_
2026-06-23 11:09:36 +08:00
parent 34d35e8f04
commit 36b4c4c60f
11 changed files with 666 additions and 51 deletions
+114 -3
View File
@@ -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<?`, 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 {
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
}