diff --git a/.github/release-notes/v1.2.29.md b/.github/release-notes/v1.2.29.md
new file mode 100644
index 0000000..e7867aa
--- /dev/null
+++ b/.github/release-notes/v1.2.29.md
@@ -0,0 +1,13 @@
+- 重构后台账号管理:新增账号统一使用“邮箱前缀 + 已有域名”创建,支持显示名称、密码二次确认、邮箱数量上限、共享存储容量和权限配置。
+- 创建用户账号时自动生成同地址默认邮箱;账号与全部绑定邮箱共用登录密码,账号重置密码后会同步更新所属邮箱。
+- 存储容量改为账号级共享:管理员默认 1 GB,普通用户默认 100 MB,最低可设置 100 MB;新增邮箱不再重复增加容量,容量检查会统计账号下全部邮箱。
+- 在账号编辑中集中管理共享存储容量,并在账号列表显示邮箱数量、共享容量、邮箱搜索与复制入口;移除邮箱地址前多余图标和重复身份标签。
+- 重做邮箱管理页面:按归属账号聚合子邮箱,支持账号或邮箱搜索、展开收起、邮箱数量统计及按地址排序,邮箱较多时仍可快速定位。
+- 默认邮箱与所属账号绑定并受到保护,管理员账号、用户默认邮箱和管理员账号下的默认邮箱均不能删除;默认邮箱也不能单独改归属或停用。
+- 普通子邮箱支持在三点菜单中启用、停用和删除;启用状态使用绿色高亮,停用状态使用红色提示,删除前必须二次确认。
+- 后台与 Open API 创建或改绑邮箱时统一继承所属账号密码和共享容量,修复遗漏容量参数时意外清零及接口绕过默认邮箱保护的问题。
+- 优化权限配置:管理员权限固定只读,普通用户系统权限和使用限制可以编辑,同时保留系统名称与说明,避免系统身份被误改。
+- 每个权限配置均提供“查看全部权限”入口,按分类展示完整权限清单,已开启项目显示勾选,未开启项目保持空框。
+- 统一后台九个功能页的卡片、表格、侧栏选中态、状态文字、操作菜单和空状态,减少重复标签、图标与视觉噪音。
+- 完善桌面端和手机端响应式布局,账号、邮箱和权限页面在窄屏下保持可读、可操作且无横向溢出。
+- 补充账号默认邮箱、管理员保护、共享容量、密码继承、Open API 兼容和权限编辑等回归测试。
diff --git a/VERSION b/VERSION
index f6b839d..550c9e9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.2.28
+1.2.29
diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go
index 8753a20..7be8332 100644
--- a/apps/api/internal/app/admin_handlers.go
+++ b/apps/api/internal/app/admin_handlers.go
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
- rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
+ rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
- GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
+ GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
@@ -64,7 +64,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
- if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
+ if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan users")
return
}
@@ -101,6 +101,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"`
Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
+ StorageQuotaMB int `json:"storageQuotaMb"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -142,6 +143,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
+ storageQuotaMB := req.StorageQuotaMB
+ if storageQuotaMB > 0 && storageQuotaMB < minimumStorageQuotaMB {
+ badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
+ return
+ }
+ if storageQuotaMB == 0 {
+ storageQuotaMB = defaultUserStorageQuotaMB
+ }
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
@@ -159,11 +168,22 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
- if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
+ if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,storage_quota_mb,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, now, now); err != nil {
badRequest(w, err)
return
}
+ localPart, domainName, _ := strings.Cut(primaryEmail, "@")
+ var primaryDomainID string
+ if err := tx.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE lower(name)=lower(?)`, domainName).Scan(&primaryDomainID); err == nil {
+ if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, id, primaryDomainID, localPart, displayName, string(passwordHash), storageQuotaMB, "active"); err != nil {
+ badRequest(w, err)
+ return
+ }
+ } else if !errors.Is(err, sql.ErrNoRows) {
+ respondError(w, http.StatusInternalServerError, "failed to load account domain")
+ return
+ }
permissionGroupIDs := req.PermissionGroupIDs
if role == "admin" {
permissionGroupIDs = nil
@@ -194,6 +214,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
+ StorageQuotaMB *int `json:"storageQuotaMb"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
@@ -267,6 +288,18 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
+ var storageQuotaMB int
+ if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, id).Scan(&storageQuotaMB); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to load storage quota")
+ return
+ }
+ if req.StorageQuotaMB != nil {
+ storageQuotaMB = *req.StorageQuotaMB
+ }
+ if storageQuotaMB < 100 {
+ badRequest(w, errors.New("共享存储容量不能小于 100 MB"))
+ return
+ }
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
badRequest(w, err)
return
@@ -311,8 +344,8 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
- if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
- loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
+ if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, storage_quota_mb=?, updated_at=? WHERE id=?`,
+ loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), storageQuotaMB, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
badRequest(w, errors.New("主登录邮箱已被使用"))
return
@@ -409,11 +442,8 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if target, err := a.userByID(r.Context(), id); err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
- } else if a.isDefaultAdminUser(target) {
- badRequest(w, errors.New("default administrator cannot be deleted"))
- return
- } else if target.Role == "admin" && (current == nil || current.Role != "admin") {
- respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
+ } else if target.Role == "admin" {
+ badRequest(w, errors.New("administrator accounts cannot be deleted"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
@@ -553,6 +583,7 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created)
items = append(items, m)
}
+ markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
@@ -563,7 +594,6 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
DisplayName string `json:"displayName"`
Password string `json:"password"`
QuotaMB int `json:"quotaMb"`
- Role string `json:"role"`
OwnerLoginName string `json:"ownerLoginName"`
OwnerEmail string `json:"ownerEmail"`
UserID string `json:"userId"`
@@ -580,20 +610,9 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
- if !hasMinimumPasswordLength(req.Password) {
- badRequest(w, errors.New("password must be at least 6 characters"))
- return
- }
- role := req.Role
- if role == "" {
- role = "user"
- }
- if role != "user" && role != "admin" {
- badRequest(w, errors.New("invalid role"))
- return
- }
- if role == "admin" {
- respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
+ userID := strings.TrimSpace(req.UserID)
+ if req.QuotaMB < 0 {
+ badRequest(w, errors.New("quotaMb must be zero or greater"))
return
}
@@ -611,15 +630,14 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
- now := a.now().UTC().Format(time.RFC3339Nano)
- userID := strings.TrimSpace(req.UserID)
displayName := req.DisplayName
if displayName == "" {
displayName = address
}
+ var disabled, ownerStorageQuotaMB int
+ var passwordHash, ownerRole string
if userID != "" {
- var disabled int
- if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
+ if err := tx.QueryRowContext(r.Context(), `SELECT disabled,password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found")
} else {
@@ -627,11 +645,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
}
return
}
- if intBool(disabled) {
- badRequest(w, errors.New("owner user is disabled"))
+ } else {
+ if !hasMinimumPasswordLength(req.Password) {
+ badRequest(w, errors.New("password must be at least 6 characters"))
return
}
- } else {
ownerEmailInput := req.OwnerEmail
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
ownerEmailInput = req.OwnerLoginName
@@ -641,21 +659,20 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
- err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
+ err = tx.QueryRowContext(r.Context(), `SELECT id,disabled,password_hash,role,storage_quota_mb FROM users WHERE email=?`, ownerEmail).Scan(&userID, &disabled, &passwordHash, &ownerRole, &ownerStorageQuotaMB)
if errors.Is(err, sql.ErrNoRows) {
- passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
- if err != nil {
+ hash, hashErr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+ if hashErr != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
userID = newID("usr")
- ownerDisplayName := displayName
- if !strings.EqualFold(ownerEmail, address) {
- ownerDisplayName = ownerEmail
- }
- _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
- if err != nil {
+ passwordHash = string(hash)
+ ownerRole = "user"
+ ownerStorageQuotaMB = defaultUserStorageQuotaMB
+ now := a.now().UTC().Format(time.RFC3339Nano)
+ if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, displayName, ownerRole, passwordHash, 0, ownerStorageQuotaMB, now, now); err != nil {
badRequest(w, err)
return
}
@@ -664,16 +681,26 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
}
- if err := tx.Commit(); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
+ if intBool(disabled) {
+ badRequest(w, errors.New("owner user is disabled"))
return
}
-
- mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
+ quotaMB := req.QuotaMB
+ if quotaMB == 0 {
+ quotaMB = ownerStorageQuotaMB
+ }
+ if ownerRole == "admin" {
+ quotaMB = 0
+ }
+ mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, local, displayName, passwordHash, quotaMB, "active")
if err != nil {
badRequest(w, err)
return
}
+ if err := tx.Commit(); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to create mailbox")
+ return
+ }
m, err := a.mailboxByID(r.Context(), mailboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
@@ -699,8 +726,9 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName is required"))
return
}
- if req.QuotaMB <= 0 {
- req.QuotaMB = 1024
+ if req.QuotaMB < 0 {
+ badRequest(w, errors.New("quotaMb must be zero or greater"))
+ return
}
status := strings.TrimSpace(req.Status)
if status == "" {
@@ -710,13 +738,27 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid status"))
return
}
+ existingMailbox, err := a.mailboxByID(r.Context(), id)
+ if err != nil {
+ respondError(w, http.StatusNotFound, "mailbox not found")
+ return
+ }
+ if existingMailbox.Primary && status != existingMailbox.Status {
+ badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
+ return
+ }
userID := strings.TrimSpace(req.UserID)
if userID == "" {
badRequest(w, errors.New("userId is required"))
return
}
+ if existingMailbox.Primary && userID != existingMailbox.UserID {
+ badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
+ return
+ }
var disabled int
- if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
+ var ownerRole, ownerPasswordHash string
+ if err := a.db.QueryRowContext(r.Context(), `SELECT disabled,role,password_hash FROM users WHERE id=?`, userID).Scan(&disabled, &ownerRole, &ownerPasswordHash); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "owner user not found")
} else {
@@ -728,8 +770,11 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("owner user is disabled"))
return
}
- res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
- userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
+ if ownerRole == "admin" {
+ req.QuotaMB = 0
+ }
+ res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
+ userID, displayName, ownerPasswordHash, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
@@ -749,6 +794,14 @@ func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
+ if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
+ } else {
+ badRequest(w, err)
+ }
+ return
+ }
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -1118,15 +1171,15 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
}
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
- row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
+ row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
WHERE u.id=?
- GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
+ GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.storage_quota_mb,u.created_at`, id)
var item AdminUser
var disabled, twoFactorEnabled int
var mailboxLimitOverride sql.NullInt64
var created, mailboxCSV string
- if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
+ if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &item.StorageQuotaMB, &created, &item.MailboxCount, &mailboxCSV); err != nil {
return nil, err
}
item.Disabled = intBool(disabled)
@@ -1191,9 +1244,48 @@ func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
return nil, err
}
m.CreatedAt = parseTime(created)
+ if err := a.markMailboxPrimary(ctx, &m); err != nil {
+ return nil, err
+ }
return &m, nil
}
+func markPrimaryMailboxes(items []Mailbox) {
+ primaryByUser := make(map[string]int)
+ for i := range items {
+ candidate, ok := primaryByUser[items[i].UserID]
+ if !ok || strings.EqualFold(items[i].Address, items[i].UserEmail) || (!strings.EqualFold(items[candidate].Address, items[candidate].UserEmail) && (items[i].CreatedAt.Before(items[candidate].CreatedAt) || (items[i].CreatedAt.Equal(items[candidate].CreatedAt) && items[i].ID < items[candidate].ID))) {
+ primaryByUser[items[i].UserID] = i
+ }
+ }
+ for _, index := range primaryByUser {
+ items[index].Primary = true
+ }
+}
+
+func (a *App) markMailboxPrimary(ctx context.Context, mailbox *Mailbox) error {
+ var primaryID string
+ err := a.db.QueryRowContext(ctx, `SELECT mb.id FROM mailboxes mb JOIN users u ON u.id=mb.user_id
+ WHERE mb.user_id=?
+ ORDER BY CASE WHEN lower(mb.address)=lower(u.email) THEN 0 ELSE 1 END, mb.created_at, mb.id LIMIT 1`, mailbox.UserID).Scan(&primaryID)
+ if err != nil {
+ return err
+ }
+ mailbox.Primary = mailbox.ID == primaryID
+ return nil
+}
+
+func (a *App) ensureMailboxDeletable(ctx context.Context, id string) error {
+ mailbox, err := a.mailboxByID(ctx, id)
+ if err != nil {
+ return err
+ }
+ if mailbox.Primary {
+ return errors.New("用户默认邮箱不能删除")
+ }
+ return nil
+}
+
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
var m Mailbox
diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go
index 0bb680d..a24a200 100644
--- a/apps/api/internal/app/app.go
+++ b/apps/api/internal/app/app.go
@@ -40,6 +40,12 @@ type App struct {
telegramDeliveryMu sync.Mutex
}
+const (
+ defaultUserStorageQuotaMB = 100
+ defaultAdminStorageQuotaMB = 1024
+ minimumStorageQuotaMB = 100
+)
+
func (a *App) config() Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
@@ -97,6 +103,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close()
return nil, err
}
+ if err := a.normalizeAdministratorMailboxQuotas(context.Background()); err != nil {
+ db.Close()
+ return nil, err
+ }
if err := a.initializeTelegramNotificationDefaults(context.Background()); err != nil {
db.Close()
return nil, err
@@ -168,6 +178,7 @@ func (a *App) migrate(ctx context.Context) error {
two_factor_secret TEXT NOT NULL DEFAULT '',
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
mailbox_limit_override INTEGER,
+ storage_quota_mb INTEGER NOT NULL DEFAULT 100,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -673,6 +684,9 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
return err
}
+ if err := a.migrateUserStorageQuota(ctx); err != nil {
+ return err
+ }
if err := a.migrateMailRulesBuilder(ctx); err != nil {
return err
}
@@ -1335,6 +1349,39 @@ func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
return err
}
+func (a *App) migrateUserStorageQuota(ctx context.Context) error {
+ rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
+ if err != nil {
+ return err
+ }
+ hasColumn := 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 == "storage_quota_mb" {
+ hasColumn = true
+ }
+ }
+ if err := rows.Close(); err != nil {
+ return err
+ }
+ if hasColumn {
+ return nil
+ }
+ if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN storage_quota_mb INTEGER NOT NULL DEFAULT 100`); err != nil {
+ return err
+ }
+ _, err = a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=? WHERE role='admin'`, defaultAdminStorageQuotaMB)
+ return err
+}
+
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
@@ -1513,8 +1560,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
- if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
+ if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, defaultAdminStorageQuotaMB, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
@@ -1535,7 +1582,7 @@ func (a *App) seed(ctx context.Context) error {
}
// Create mailbox for admin
- mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active")
+ mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 0, "active")
if err != nil {
return err
}
@@ -1656,7 +1703,7 @@ func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error
if !errors.Is(err, sql.ErrNoRows) {
return err
}
- mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active")
+ mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 0, "active")
if err != nil {
return err
}
@@ -1871,8 +1918,15 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
if localPart == "" {
return "", errors.New("invalid local part")
}
- if quotaMB <= 0 {
- quotaMB = 1024
+ if quotaMB < 0 {
+ return "", errors.New("quotaMb must be zero or greater")
+ }
+ var ownerRole string
+ if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=?`, userID).Scan(&ownerRole); err != nil {
+ return "", err
+ }
+ if ownerRole == "admin" {
+ quotaMB = 0
}
if status == "" {
status = "active"
@@ -1905,6 +1959,17 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
return id, nil
}
+func (a *App) normalizeAdministratorMailboxQuotas(ctx context.Context) error {
+ now := a.now().UTC().Format(time.RFC3339Nano)
+ if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=CASE WHEN role='admin' THEN ? ELSE ? END, updated_at=? WHERE storage_quota_mb`, defaultAdminStorageQuotaMB, defaultUserStorageQuotaMB, now, minimumStorageQuotaMB); err != nil {
+ return err
+ }
+ _, err := a.db.ExecContext(ctx, `UPDATE mailboxes
+ SET quota_mb=0, updated_at=?
+ WHERE quota_mb<>0 AND user_id IN (SELECT id FROM users WHERE role='admin')`, now)
+ return err
+}
+
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go
index 82abaa2..2579664 100644
--- a/apps/api/internal/app/app_test.go
+++ b/apps/api/internal/app/app_test.go
@@ -363,6 +363,147 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
return mailbox
}
+func TestAdminMailboxCreationUsesOwnerPasswordAndQuota(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("login code=%d body=%v", code, login)
+ }
+ adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
+ adminDetails, err := a.adminUserByID(context.Background(), adminUser.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if adminDetails.StorageQuotaMB != defaultAdminStorageQuotaMB {
+ t.Fatalf("administrator storage quota=%d, want %d", adminDetails.StorageQuotaMB, defaultAdminStorageQuotaMB)
+ }
+ domainID := mustDefaultDomainID(t, a)
+
+ var secondary Mailbox
+ if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
+ "domainId": domainID,
+ "localPart": "admin-secondary",
+ "userId": adminUser.ID,
+ }, &secondary); code != http.StatusCreated {
+ t.Fatalf("create admin secondary mailbox code=%d", code)
+ }
+ if secondary.QuotaMB != 0 {
+ t.Fatalf("admin secondary quota=%d, want unlimited", secondary.QuotaMB)
+ }
+
+ var primaryHash, secondaryHash string
+ if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, adminMailbox.ID).Scan(&primaryHash); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, secondary.ID).Scan(&secondaryHash); err != nil {
+ t.Fatal(err)
+ }
+ if primaryHash != secondaryHash {
+ t.Fatal("admin secondary mailbox did not inherit the owner password")
+ }
+
+ var regular AdminUser
+ if code := admin.do("POST", "/api/admin/users", map[string]any{
+ "email": "owner@lanqin.local",
+ "displayName": "Owner",
+ "password": "OwnerPassword123!",
+ "role": "user",
+ }, ®ular); code != http.StatusCreated {
+ t.Fatalf("create regular owner code=%d", code)
+ }
+ if regular.MailboxCount != 1 {
+ t.Fatalf("new account mailbox count=%d, want one protected primary mailbox", regular.MailboxCount)
+ }
+ if regular.StorageQuotaMB != defaultUserStorageQuotaMB {
+ t.Fatalf("regular account storage quota=%d, want %d", regular.StorageQuotaMB, defaultUserStorageQuotaMB)
+ }
+ if _, err := a.mailboxByAddress(context.Background(), regular.Email); err != nil {
+ t.Fatalf("new account primary mailbox missing: %v", err)
+ }
+ var primaryStatusErr map[string]any
+ primaryMailbox, err := a.mailboxByAddress(context.Background(), regular.Email)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code := admin.do("POST", "/api/admin/mailboxes/"+primaryMailbox.ID, map[string]any{
+ "userId": regular.ID, "displayName": primaryMailbox.DisplayName, "quotaMb": primaryMailbox.QuotaMB, "status": "disabled",
+ }, &primaryStatusErr); code != http.StatusBadRequest {
+ t.Fatalf("primary mailbox status update code=%d body=%v", code, primaryStatusErr)
+ }
+ var regularMailbox Mailbox
+ if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{
+ "domainId": domainID,
+ "localPart": "owner-secondary",
+ "userId": regular.ID,
+ }, ®ularMailbox); code != http.StatusCreated {
+ t.Fatalf("create regular secondary mailbox code=%d", code)
+ }
+ if regularMailbox.QuotaMB != defaultUserStorageQuotaMB {
+ t.Fatalf("regular secondary quota=%d, want %d", regularMailbox.QuotaMB, defaultUserStorageQuotaMB)
+ }
+ var userHash, mailboxHash string
+ if err := a.db.QueryRow(`SELECT password_hash FROM users WHERE id=?`, regular.ID).Scan(&userHash); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.db.QueryRow(`SELECT password_hash FROM mailboxes WHERE id=?`, regularMailbox.ID).Scan(&mailboxHash); err != nil {
+ t.Fatal(err)
+ }
+ if userHash != mailboxHash {
+ t.Fatal("regular secondary mailbox did not inherit the owner password")
+ }
+}
+
+func TestAdministratorAccountAndPrimaryMailboxesCannotBeDeleted(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("login code=%d body=%v", code, login)
+ }
+ adminUser, adminMailbox := defaultAdminUserAndMailbox(t, a)
+ var errBody map[string]any
+ if code := admin.do("DELETE", "/api/admin/users/"+adminUser.ID, nil, &errBody); code != http.StatusBadRequest {
+ t.Fatalf("administrator account delete code=%d body=%v", code, errBody)
+ }
+ if code := admin.do("DELETE", "/api/admin/mailboxes/"+adminMailbox.ID, nil, &errBody); code != http.StatusBadRequest {
+ t.Fatalf("administrator primary mailbox delete code=%d body=%v", code, errBody)
+ }
+ if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
+ "email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 99,
+ }, &errBody); code != http.StatusBadRequest {
+ t.Fatalf("storage quota below 100 MB code=%d body=%v", code, errBody)
+ }
+ var updatedAdmin AdminUser
+ if code := admin.do("POST", "/api/admin/users/"+adminUser.ID, map[string]any{
+ "email": adminUser.Email, "displayName": adminUser.DisplayName, "role": "admin", "disabled": false, "storageQuotaMb": 100,
+ }, &updatedAdmin); code != http.StatusOK || updatedAdmin.StorageQuotaMB != 100 {
+ t.Fatalf("administrator storage quota code=%d user=%+v", code, updatedAdmin)
+ }
+
+ var regular AdminUser
+ if code := admin.do("POST", "/api/admin/users", map[string]any{
+ "email": "protected-primary@lanqin.local", "displayName": "Protected Primary", "role": "user", "password": "Password123!",
+ }, ®ular); code != http.StatusCreated {
+ t.Fatalf("create regular user code=%d user=%+v", code, regular)
+ }
+ primary, err := a.mailboxByAddress(context.Background(), regular.Email)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code := admin.do("DELETE", "/api/admin/mailboxes/"+primary.ID, nil, &errBody); code != http.StatusBadRequest {
+ t.Fatalf("regular primary mailbox delete code=%d body=%v", code, errBody)
+ }
+ secondary := createTestMailbox(t, admin, primary.DomainID, "deletable-secondary", "Secondary", "", map[string]any{"userId": regular.ID})
+ if code := admin.do("DELETE", "/api/admin/mailboxes/"+secondary.ID, nil, &errBody); code != http.StatusOK {
+ t.Fatalf("secondary mailbox delete code=%d body=%v", code, errBody)
+ }
+}
+
func createTestAPIToken(t *testing.T, client *testClient, name string) string {
return createTestAPITokenWithScopes(t, client, name, nil)
}
@@ -1574,6 +1715,13 @@ func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
if code := client.do("POST", "/api/auth/register", registration, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
t.Fatalf("register code=%d user=%+v", code, registered.User)
}
+ var storageQuotaMB int
+ if err := a.db.QueryRow(`SELECT storage_quota_mb FROM users WHERE id=?`, registered.User.ID).Scan(&storageQuotaMB); err != nil {
+ t.Fatal(err)
+ }
+ if storageQuotaMB != defaultUserStorageQuotaMB {
+ t.Fatalf("registered account storage quota=%d, want %d", storageQuotaMB, defaultUserStorageQuotaMB)
+ }
var me struct {
User User `json:"user"`
}
@@ -3177,20 +3325,45 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK {
t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain)
}
+ var owner AdminUser
+ if code := admin.do("POST", "/api/admin/users", map[string]any{
+ "email": "open-api-owner@lanqin.local", "displayName": "Open API Owner", "role": "user", "password": "Password123!",
+ }, &owner); code != http.StatusCreated {
+ t.Fatalf("create open api mailbox owner code=%d owner=%+v", code, owner)
+ }
var mailbox Mailbox
if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{
"domainId": domain.ID,
"localPart": "api-user",
"displayName": "API User",
- "password": "Password123!",
+ "password": "DifferentPassword123!",
"quotaMb": 256,
+ "userId": owner.ID,
}, &mailbox); code != http.StatusCreated {
t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox)
}
+ ownerPrimary, err := a.mailboxByAddress(context.Background(), owner.Email)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var protectedMailboxErr map[string]any
+ if code := openAdmin.do("POST", "/api/open/mailboxes/"+ownerPrimary.ID, map[string]any{"status": "disabled"}, &protectedMailboxErr); code != http.StatusBadRequest {
+ t.Fatalf("open api primary mailbox status update code=%d body=%v", code, protectedMailboxErr)
+ }
if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 {
t.Fatalf("mailbox=%+v", mailbox)
}
+ var ownerPasswordHash, mailboxPasswordHash string
+ if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM users WHERE id=?`, owner.ID).Scan(&ownerPasswordHash); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.db.QueryRowContext(context.Background(), `SELECT password_hash FROM mailboxes WHERE id=?`, mailbox.ID).Scan(&mailboxPasswordHash); err != nil {
+ t.Fatal(err)
+ }
+ if mailboxPasswordHash != ownerPasswordHash {
+ t.Fatal("open api mailbox did not inherit the owner password")
+ }
var mailboxes struct {
Items []Mailbox `json:"items"`
}
@@ -3207,6 +3380,9 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" {
t.Fatalf("updated mailbox=%+v", updated)
}
+ if code := openAdmin.do("POST", "/api/open/mailboxes/"+mailbox.ID, map[string]any{"status": "active"}, &updated); code != http.StatusOK || updated.QuotaMB != 512 {
+ t.Fatalf("open api mailbox omitted quota should preserve 512 MB: code=%d mailbox=%+v", code, updated)
+ }
var ok map[string]any
if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete open api mailbox code=%d body=%v", code, ok)
@@ -5195,20 +5371,24 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
}, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
}
- var regularUpdateErr map[string]any
+ var updatedRegular PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
"name": "Changed Regular",
"description": "Should not change",
- "permissions": []string{PermissionAdminOverview},
- }, ®ularUpdateErr); code != http.StatusForbidden {
- t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
+ "permissions": regularUserDefaultPermissions(),
+ "limits": defaultPermissionLimits(),
+ }, &updatedRegular); code != http.StatusOK {
+ t.Fatalf("regular system permission group update code=%d group=%+v", code, updatedRegular)
+ }
+ if updatedRegular.Name != "普通用户" || updatedRegular.Description != "仅可使用自己的邮箱功能,不包含后台权限。" {
+ t.Fatalf("regular system permission group identity changed: %+v", updatedRegular)
}
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
if err != nil {
t.Fatal(err)
}
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
- t.Fatalf("regular group should stay locked with default permissions=%+v", regularGroup)
+ t.Fatalf("regular group should retain the saved default permissions=%+v", regularGroup)
}
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
@@ -6357,7 +6537,7 @@ func TestMailboxQuotaRejectsNewMessage(t *testing.T) {
ctx := context.Background()
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
- if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET quota_mb=1 WHERE id=?`, mb.ID); err != nil {
+ if _, err := a.db.ExecContext(ctx, `UPDATE users SET storage_quota_mb=1 WHERE id=?`, user.ID); err != nil {
t.Fatal(err)
}
_, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
@@ -6433,7 +6613,11 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) {
if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK {
t.Fatalf("stats code=%d stats=%+v", code, stats)
}
- if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
+ var aliceStorageQuotaMB int64
+ if err := a.db.QueryRowContext(ctx, `SELECT storage_quota_mb FROM users WHERE id=?`, aliceUser.ID).Scan(&aliceStorageQuotaMB); err != nil {
+ t.Fatal(err)
+ }
+ if stats.QuotaBytes != aliceStorageQuotaMB*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 {
t.Fatalf("stats quota/attachment not populated: %+v", stats)
}
if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 {
diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go
index 7dbf06e..d60fc97 100644
--- a/apps/api/internal/app/auth_handlers.go
+++ b/apps/api/internal/app/auth_handlers.go
@@ -177,8 +177,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
- if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
+ if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, defaultUserStorageQuotaMB, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -186,7 +186,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
- if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
+ if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), defaultUserStorageQuotaMB, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
} else {
diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go
index 5df1d6a..5c29ac4 100644
--- a/apps/api/internal/app/mail_handlers.go
+++ b/apps/api/internal/app/mail_handlers.go
@@ -87,6 +87,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
m.CreatedAt = parseTime(created)
items = append(items, m)
}
+ markPrimaryMailboxes(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
@@ -2619,14 +2620,15 @@ func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, ma
return nil
}
var quotaMB int64
- if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan("aMB); err != nil {
+ var userID string
+ if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan("aMB, &userID); err != nil {
return err
}
if quotaMB <= 0 {
return nil
}
var used int64
- if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil {
+ if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
return err
}
quotaBytes := quotaMB * 1024 * 1024
diff --git a/apps/api/internal/app/mailbox_delete_test.go b/apps/api/internal/app/mailbox_delete_test.go
index ee8d40e..ee2ec41 100644
--- a/apps/api/internal/app/mailbox_delete_test.go
+++ b/apps/api/internal/app/mailbox_delete_test.go
@@ -6,7 +6,7 @@ import (
"testing"
)
-func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
+func TestAdminCannotDeleteOwnPrimaryMailbox(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -21,10 +21,13 @@ func TestAdminCanDeleteOwnLastMailboxWithoutDeletingAccount(t *testing.T) {
if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
t.Fatalf("mailboxes code=%d items=%d", code, len(mailboxes.Items))
}
- if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusOK {
- t.Fatalf("delete final mailbox code=%d", code)
+ if !mailboxes.Items[0].Primary {
+ t.Fatal("administrator mailbox should be marked as primary")
}
- if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 0 {
+ if code := admin.do("DELETE", "/api/admin/mailboxes/"+mailboxes.Items[0].ID, nil, &map[string]any{}); code != http.StatusBadRequest {
+ t.Fatalf("delete primary mailbox code=%d", code)
+ }
+ if code := admin.do("GET", "/api/mail/mailboxes", nil, &mailboxes); code != http.StatusOK || len(mailboxes.Items) != 1 {
t.Fatalf("mailboxes after delete code=%d items=%d", code, len(mailboxes.Items))
}
var me map[string]any
diff --git a/apps/api/internal/app/open_api_handlers.go b/apps/api/internal/app/open_api_handlers.go
index 5756ead..a160513 100644
--- a/apps/api/internal/app/open_api_handlers.go
+++ b/apps/api/internal/app/open_api_handlers.go
@@ -200,6 +200,10 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
+ if req.QuotaMB < 0 {
+ badRequest(w, errors.New("quotaMb must be zero or greater"))
+ return
+ }
domain, err := a.domainByID(r.Context(), req.DomainID)
if err != nil {
respondError(w, http.StatusNotFound, "domain not found")
@@ -231,7 +235,20 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
respondMailboxOwnerError(w, err)
return
}
- mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
+ var ownerPasswordHash, ownerRole string
+ var ownerStorageQuotaMB int
+ if err := tx.QueryRowContext(r.Context(), `SELECT password_hash,role,storage_quota_mb FROM users WHERE id=?`, userID).Scan(&ownerPasswordHash, &ownerRole, &ownerStorageQuotaMB); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to load owner user")
+ return
+ }
+ quotaMB := req.QuotaMB
+ if quotaMB == 0 {
+ quotaMB = ownerStorageQuotaMB
+ }
+ if ownerRole == "admin" {
+ quotaMB = 0
+ }
+ mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, ownerPasswordHash, quotaMB, "active")
if err != nil {
badRequest(w, err)
return
@@ -279,7 +296,11 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
displayName = current.DisplayName
}
quotaMB := req.QuotaMB
- if quotaMB <= 0 {
+ if quotaMB < 0 {
+ badRequest(w, errors.New("quotaMb must be zero or greater"))
+ return
+ }
+ if quotaMB == 0 {
quotaMB = current.QuotaMB
}
status := strings.TrimSpace(req.Status)
@@ -294,12 +315,28 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
if userID == "" {
userID = current.UserID
}
+ if current.Primary && userID != current.UserID {
+ badRequest(w, errors.New("用户默认邮箱归属由所属账号管理,不能单独修改"))
+ return
+ }
+ if current.Primary && status != current.Status {
+ badRequest(w, errors.New("用户默认邮箱状态由所属账号管理,不能单独修改"))
+ return
+ }
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
respondMailboxOwnerError(w, err)
return
}
- res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
- userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
+ var ownerRole, ownerPasswordHash string
+ if err := a.db.QueryRowContext(r.Context(), `SELECT role,password_hash FROM users WHERE id=?`, userID).Scan(&ownerRole, &ownerPasswordHash); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to load owner user")
+ return
+ }
+ if ownerRole == "admin" {
+ quotaMB = 0
+ }
+ res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,password_hash=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
+ userID, displayName, ownerPasswordHash, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
return
@@ -318,6 +355,14 @@ func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request)
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
+ if err := a.ensureMailboxDeletable(r.Context(), id); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ respondError(w, http.StatusNotFound, "邮箱不存在或已被删除")
+ } else {
+ badRequest(w, err)
+ }
+ return
+ }
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "加载邮箱邮件失败")
@@ -821,8 +866,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
if displayName == "" {
displayName = email
}
- _, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now)
+ _, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,storage_quota_mb,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, defaultUserStorageQuotaMB, now, now)
return userID, err
}
diff --git a/apps/api/internal/app/permission_group_handlers.go b/apps/api/internal/app/permission_group_handlers.go
index 89aae09..9cb5861 100644
--- a/apps/api/internal/app/permission_group_handlers.go
+++ b/apps/api/internal/app/permission_group_handlers.go
@@ -142,11 +142,12 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var existingSystem int
- if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
+ var existingName, existingDescription string
+ if err := a.db.QueryRowContext(r.Context(), `SELECT system,name,description FROM permission_groups WHERE id=?`, id).Scan(&existingSystem, &existingName, &existingDescription); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
- if intBool(existingSystem) {
+ if intBool(existingSystem) && id != PermissionGroupRegular {
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
return
}
@@ -161,6 +162,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
return
}
name := strings.TrimSpace(req.Name)
+ if id == PermissionGroupRegular {
+ name = existingName
+ req.Description = existingDescription
+ }
if name == "" {
badRequest(w, errors.New("name is required"))
return
diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go
index 2735fb1..e769238 100644
--- a/apps/api/internal/app/personal_handlers.go
+++ b/apps/api/internal/app/personal_handlers.go
@@ -111,7 +111,11 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("displayName must be at most 80 characters"))
return
}
- mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active")
+ quotaMB := defaultUserStorageQuotaMB
+ if user.Role == "admin" {
+ quotaMB = 0
+ }
+ mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, quotaMB, "active")
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
@@ -900,26 +904,14 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
return
}
- if mailboxID != "" && !isAllMailboxID(mailboxID) {
- var quotaMB int64
- if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to load quota")
- return
- }
- stats.QuotaBytes = quotaMB * 1024 * 1024
- if stats.QuotaBytes > 0 {
- stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
- }
- } else {
- var quotaMB int64
- if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan("aMB); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to load quota")
- return
- }
- stats.QuotaBytes = quotaMB * 1024 * 1024
- if stats.QuotaBytes > 0 {
- stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
- }
+ var quotaMB int64
+ if err := a.db.QueryRowContext(r.Context(), `SELECT storage_quota_mb FROM users WHERE id=?`, user.ID).Scan("aMB); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to load quota")
+ return
+ }
+ stats.QuotaBytes = quotaMB * 1024 * 1024
+ if stats.QuotaBytes > 0 {
+ stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go
index de31c5c..4f4f025 100644
--- a/apps/api/internal/app/types.go
+++ b/apps/api/internal/app/types.go
@@ -21,8 +21,9 @@ type User struct {
type AdminUser struct {
User
- MailboxCount int `json:"mailboxCount"`
- Mailboxes []string `json:"mailboxes"`
+ MailboxCount int `json:"mailboxCount"`
+ Mailboxes []string `json:"mailboxes"`
+ StorageQuotaMB int `json:"storageQuotaMb"`
}
type APIToken struct {
@@ -71,6 +72,7 @@ type Mailbox struct {
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
+ Primary bool `json:"primary"`
UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"`
}
diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx
index 70db88f..d0d3b90 100644
--- a/apps/web/src/components/protected-layout.tsx
+++ b/apps/web/src/components/protected-layout.tsx
@@ -5,7 +5,6 @@ import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SystemVersionDialog } from "@/components/system-version-dialog"
import { hasAnyPermission } from "@/lib/permissions"
@@ -110,9 +109,6 @@ function ProtectedContent() {
{user.displayName}
{user.email}
-