diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go
index ced3146..f9bd9ae 100644
--- a/apps/api/internal/app/app_test.go
+++ b/apps/api/internal/app/app_test.go
@@ -170,6 +170,19 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
return mailbox
}
+func updateRegularPermissionGroup(t *testing.T, admin *testClient, permissions []string) 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,
+ }, &group); code != http.StatusOK {
+ t.Fatalf("update regular permission group code=%d group=%+v", code, group)
+ }
+ return group
+}
+
func systemSettingsPayload(settings SystemSettings) map[string]any {
return map[string]any{
"publicHostname": settings.PublicHostname,
@@ -938,14 +951,7 @@ 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 regularGroup PermissionGroup
- if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
- "name": "普通用户",
- "description": "Default permissions for regular users",
- "permissions": []string{PermissionAdminOverview},
- }, ®ularGroup); code != http.StatusOK {
- t.Fatalf("regular user group should be editable code=%d group=%+v", code, regularGroup)
- }
+ regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup)
}
@@ -1205,6 +1211,75 @@ func TestLegacySystemPermissionGroupsAreCleanedUp(t *testing.T) {
}
}
+func TestRegularUserMailPermissionsAreEnforced(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)
+ }
+ mb := createTestMailbox(t, admin, mustDefaultDomainID(t, a), "front-perm", "Front Permissions", "Password123!", nil)
+
+ user := &testClient{t: t, server: ts}
+ if code := user.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
+ t.Fatalf("user login code=%d", code)
+ }
+ var mine struct {
+ Items []Mailbox `json:"items"`
+ }
+ if code := user.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].ID != mb.ID {
+ t.Fatalf("regular user should access mail front code=%d items=%+v", code, mine.Items)
+ }
+ var errBody map[string]any
+ if code := user.do("GET", "/api/admin/overview", nil, &errBody); code != http.StatusForbidden {
+ t.Fatalf("regular mail permissions should not grant admin access code=%d body=%v", code, errBody)
+ }
+
+ updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess))
+ noAccess := &testClient{t: t, server: ts}
+ if code := noAccess.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
+ t.Fatalf("no access login code=%d", code)
+ }
+ if code := noAccess.do("GET", "/api/mail/mailboxes", nil, &errBody); code != http.StatusForbidden {
+ t.Fatalf("missing mail access should block mailbox list code=%d body=%v", code, errBody)
+ }
+
+ updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend))
+ noSend := &testClient{t: t, server: ts}
+ if code := noSend.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
+ t.Fatalf("no send login code=%d", code)
+ }
+ sendPayload := map[string]any{
+ "mailboxId": mb.ID,
+ "to": []string{"someone@example.test"},
+ "subject": "blocked send",
+ "text": "body",
+ "html": "
body
",
+ }
+ if code := noSend.do("POST", "/api/mail/send", sendPayload, &errBody); code != http.StatusForbidden {
+ t.Fatalf("missing send permission should block send code=%d body=%v", code, errBody)
+ }
+ schedulePayload := map[string]any{
+ "mailboxId": mb.ID,
+ "to": []string{"someone@example.test"},
+ "subject": "blocked schedule",
+ "text": "body",
+ "html": "body
",
+ "sendAt": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339Nano),
+ }
+ if code := noSend.do("POST", "/api/mail/schedule-send", schedulePayload, &errBody); code != http.StatusForbidden {
+ t.Fatalf("missing send permission should block scheduled send creation code=%d body=%v", code, errBody)
+ }
+ if code := noSend.do("GET", "/api/mail/scheduled-sends?mailboxId="+mb.ID, nil, &struct {
+ Items []ScheduledSend `json:"items"`
+ }{}); code != http.StatusOK {
+ t.Fatalf("schedule management permission should remain usable code=%d", code)
+ }
+}
+
func TestMaildirSyncImportsRFC822(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
@@ -1377,3 +1452,17 @@ func containsString(items []string, needle string) bool {
}
return false
}
+
+func withoutPermissions(items []string, removed ...string) []string {
+ removedSet := map[string]bool{}
+ for _, item := range removed {
+ removedSet[item] = true
+ }
+ out := make([]string, 0, len(items))
+ for _, item := range items {
+ if !removedSet[item] {
+ out = append(out, item)
+ }
+ }
+ return out
+}
diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go
index bc6e462..61deae9 100644
--- a/apps/api/internal/app/mail_handlers.go
+++ b/apps/api/internal/app/mail_handlers.go
@@ -277,7 +277,7 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "message not found")
return
}
- if r.URL.Query().Get("markRead") != "0" && !msg.IsRead {
+ if r.URL.Query().Get("markRead") != "0" && !msg.IsRead && userHasPermission(currentUser(r), PermissionMailOrganize) {
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
msg.IsRead = true
}
@@ -770,6 +770,11 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
a.markScheduledSendFailed(ctx, id, "mailbox not found")
return
}
+ user, err := a.userByID(ctx, mb.UserID)
+ if err != nil || !userHasPermission(user, PermissionMailSchedule) || !userHasPermission(user, PermissionMailSend) {
+ a.markScheduledSendFailed(ctx, id, "mail send permission revoked")
+ 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 {
a.markScheduledSendFailed(ctx, id, err.Error())
diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go
index f369b41..02f35f6 100644
--- a/apps/api/internal/app/permissions.go
+++ b/apps/api/internal/app/permissions.go
@@ -13,6 +13,23 @@ import (
)
const (
+ PermissionMailAccess = "mail.access"
+
+ PermissionMailRead = "mail.messages.read"
+ PermissionMailSend = "mail.messages.send"
+ PermissionMailDrafts = "mail.messages.drafts"
+ PermissionMailSchedule = "mail.messages.schedule"
+ PermissionMailOrganize = "mail.messages.organize"
+ PermissionMailLabels = "mail.labels.manage"
+ PermissionMailAttachments = "mail.attachments.download"
+
+ PermissionMailContacts = "mail.contacts.manage"
+ PermissionMailSignatures = "mail.signatures.manage"
+ PermissionMailRules = "mail.rules.manage"
+ PermissionMailBlocked = "mail.blocked_senders.manage"
+ PermissionMailStats = "mail.stats.view"
+ PermissionMailboxApply = "mail.mailboxes.apply"
+
PermissionAdminOverview = "admin.overview.view"
PermissionUsersView = "admin.users.view"
@@ -65,6 +82,8 @@ const (
PermissionSystemSettings = PermissionSettingsUpdate
)
+const regularUserPermissionMigrationKey = "permission_groups.regular_mail_permissions_v1"
+
const (
PermissionGroupSuperAdmin = "pg_super_admin"
PermissionGroupRegular = "pg_regular_user"
@@ -117,6 +136,13 @@ type PermissionGroup struct {
}
var legacyPermissionExpansions = map[string][]string{
+ "mail": regularUserDefaultPermissions(),
+ "mail.messages": {PermissionMailAccess, PermissionMailRead, PermissionMailSend, PermissionMailDrafts, PermissionMailSchedule, PermissionMailOrganize, PermissionMailAttachments},
+ "mail.labels": {PermissionMailAccess, PermissionMailRead, PermissionMailLabels},
+ "mail.contacts": {PermissionMailContacts},
+ "mail.signatures": {PermissionMailSignatures},
+ "mail.rules": {PermissionMailRules, PermissionMailBlocked},
+ "mail.mailboxes": {PermissionMailboxApply},
"admin.overview": {PermissionAdminOverview},
"admin.users": {PermissionUsersView, PermissionUsersCreate, PermissionUsersUpdate, PermissionUsersDelete, PermissionUsersResetPassword, PermissionGroupsView},
"admin.permission_groups": {PermissionGroupsView, PermissionGroupsCreate, PermissionGroupsUpdate, PermissionGroupsDelete},
@@ -129,6 +155,21 @@ var legacyPermissionExpansions = map[string][]string{
}
var permissionCatalogItems = []PermissionInfo{
+ {Key: PermissionMailAccess, Label: "访问邮箱前台", Description: "进入邮箱前台并查看本人邮箱列表。", Category: "邮箱前台"},
+ {Key: PermissionMailRead, Label: "查看本人邮件", Description: "查看本人文件夹、邮件列表、星标邮件和邮件正文。", Category: "邮箱前台"},
+ {Key: PermissionMailSend, Label: "发送邮件", Description: "使用本人邮箱发送、回复和转发邮件。", Category: "邮箱前台"},
+ {Key: PermissionMailDrafts, Label: "管理草稿", Description: "保存、编辑和删除本人草稿。", Category: "邮箱前台"},
+ {Key: PermissionMailSchedule, Label: "定时发送", Description: "创建、查看和取消本人定时发送任务。", Category: "邮箱前台"},
+ {Key: PermissionMailOrganize, Label: "整理邮件", Description: "标记已读、星标、移动、归档、删除和清理本人邮件。", Category: "邮箱前台"},
+ {Key: PermissionMailLabels, Label: "管理邮件标签", Description: "创建标签,并为本人邮件添加或移除标签。", Category: "邮箱前台"},
+ {Key: PermissionMailAttachments, Label: "下载附件", Description: "下载本人邮件中的附件。", Category: "邮箱前台"},
+ {Key: PermissionMailContacts, Label: "管理联系人", Description: "查看、新增和删除本人的联系人。", Category: "个人中心"},
+ {Key: PermissionMailSignatures, Label: "管理签名", Description: "查看、新增、修改和删除本人的邮件签名。", Category: "个人中心"},
+ {Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"},
+ {Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"},
+ {Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"},
+ {Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱账号。", Category: "个人中心"},
+
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
@@ -186,6 +227,16 @@ func allPermissionKeys() []string {
return out
}
+func adminPermissionKeys() []string {
+ out := make([]string, 0, len(permissionCatalogItems))
+ for _, item := range permissionCatalogItems {
+ if strings.HasPrefix(item.Key, "admin.") {
+ out = append(out, item.Key)
+ }
+ }
+ return out
+}
+
func permissionSet() map[string]bool {
out := map[string]bool{}
for _, item := range permissionCatalogItems {
@@ -269,12 +320,31 @@ func defaultPermissionGroups() []PermissionGroup {
ID: PermissionGroupRegular,
Name: "普通用户",
Description: "仅可使用自己的邮箱功能,不包含后台权限。",
- Permissions: []string{},
+ Permissions: regularUserDefaultPermissions(),
System: true,
},
}
}
+func regularUserDefaultPermissions() []string {
+ return []string{
+ PermissionMailAccess,
+ PermissionMailRead,
+ PermissionMailSend,
+ PermissionMailDrafts,
+ PermissionMailSchedule,
+ PermissionMailOrganize,
+ PermissionMailLabels,
+ PermissionMailAttachments,
+ PermissionMailContacts,
+ PermissionMailSignatures,
+ PermissionMailRules,
+ PermissionMailBlocked,
+ PermissionMailStats,
+ PermissionMailboxApply,
+ }
+}
+
func fixedPermissionGroupIDs() map[string]bool {
out := map[string]bool{}
for _, group := range defaultPermissionGroups() {
@@ -331,9 +401,53 @@ func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
if err := a.cleanupLegacyDefaultPermissionGroups(ctx); err != nil {
return err
}
+ if err := a.ensureRegularUserMailPermissions(ctx); err != nil {
+ return err
+ }
return nil
}
+func (a *App) ensureRegularUserMailPermissions(ctx context.Context) error {
+ var migrated string
+ err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key=?`, regularUserPermissionMigrationKey).Scan(&migrated)
+ if err == nil && migrated == "1" {
+ return nil
+ }
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+ var raw string
+ if err := a.db.QueryRowContext(ctx, `SELECT permissions_json FROM permission_groups WHERE id=?`, PermissionGroupRegular).Scan(&raw); err != nil {
+ return err
+ }
+ seen := map[string]bool{}
+ for _, permission := range decodeStoredPermissions(raw) {
+ seen[permission] = true
+ }
+ changed := false
+ for _, permission := range regularUserDefaultPermissions() {
+ if !seen[permission] {
+ seen[permission] = true
+ changed = true
+ }
+ }
+ now := a.now().UTC().Format(time.RFC3339Nano)
+ if changed {
+ permissions := make([]string, 0, len(seen))
+ for _, permission := range allPermissionKeys() {
+ if seen[permission] {
+ permissions = append(permissions, permission)
+ }
+ }
+ if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET permissions_json=?, updated_at=? WHERE id=?`, encodePermissions(permissions), now, PermissionGroupRegular); err != nil {
+ return err
+ }
+ }
+ _, err = a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, regularUserPermissionMigrationKey, "1", now)
+ return err
+}
+
func (a *App) cleanupLegacyDefaultPermissionGroups(ctx context.Context) error {
for _, groupID := range legacyDefaultPermissionGroupIDs {
var userCount int
@@ -543,7 +657,7 @@ func userHasAnyPermission(user *User, permissions ...string) bool {
}
func userHasAdminAccess(user *User) bool {
- return userHasAnyPermission(user, allPermissionKeys()...)
+ return userHasAnyPermission(user, adminPermissionKeys()...)
}
func (a *App) requireAdminAccess(next http.Handler) http.Handler {
diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go
index b715c27..31287eb 100644
--- a/apps/api/internal/app/router_auth.go
+++ b/apps/api/internal/app/router_auth.go
@@ -36,53 +36,53 @@ func (a *App) Router() http.Handler {
r.With(a.requireAuth).Get("/me", a.handleMe)
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
- r.With(a.requireAuth).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
- r.With(a.requireAuth).Post("/me/mailboxes/apply", a.handleApplyMailbox)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox)
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
- r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
- r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
- r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
- r.With(a.requireAuth).Get("/me/signatures", a.handleListSignatures)
- r.With(a.requireAuth).Post("/me/signatures", a.handleCreateSignature)
- r.With(a.requireAuth).Post("/me/signatures/{id}", a.handleUpdateSignature)
- r.With(a.requireAuth).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
- r.With(a.requireAuth).Delete("/me/signatures/{id}", a.handleDeleteSignature)
- r.With(a.requireAuth).Get("/me/signatures/default", a.handleDefaultSignature)
- r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
- r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
- r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
- r.With(a.requireAuth).Get("/me/blocked-senders", a.handleListBlockedSenders)
- r.With(a.requireAuth).Post("/me/blocked-senders", a.handleCreateBlockedSender)
- r.With(a.requireAuth).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
- r.With(a.requireAuth).Get("/me/stats", a.handleMailStats)
- r.With(a.requireAuth).Post("/me/cleanup", a.handleMailCleanup)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Get("/me/contacts", a.handleListContacts)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Post("/me/contacts", a.handleCreateContact)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Delete("/me/contacts/{id}", a.handleDeleteContact)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures", a.handleListSignatures)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures", a.handleCreateSignature)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}", a.handleUpdateSignature)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Delete("/me/signatures/{id}", a.handleDeleteSignature)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures/default", a.handleDefaultSignature)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Get("/me/rules", a.handleListRules)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules", a.handleCreateRule)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Delete("/me/rules/{id}", a.handleDeleteRule)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Get("/me/blocked-senders", a.handleListBlockedSenders)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Post("/me/blocked-senders", a.handleCreateBlockedSender)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
+ r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
- r.Get("/mail/mailboxes", a.handleMyMailboxes)
- r.Get("/mail/folders", a.handleMailFolders)
- r.Get("/mail/labels", a.handleMailLabels)
- r.Post("/mail/labels", a.handleCreateMailLabel)
- r.Get("/mail/messages", a.handleMailMessages)
- r.Get("/mail/starred", a.handleStarredMessages)
- r.Get("/mail/messages/{id}", a.handleMailMessage)
- r.Post("/mail/send", a.handleMailSend)
- r.Get("/mail/scheduled-sends", a.handleScheduledSends)
- r.Post("/mail/schedule-send", a.handleScheduleSend)
- r.Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
- r.Post("/mail/drafts", a.handleSaveDraft)
- r.Post("/mail/drafts/{id}", a.handleSaveDraft)
- r.Delete("/mail/drafts/{id}", a.handleDeleteDraft)
- r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
- r.Post("/mail/messages/{id}/star", a.handleStar)
- r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
- r.Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
- r.Post("/mail/messages/{id}/move", a.handleMove)
- r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
- r.Get("/mail/attachments/{id}", a.handleAttachment)
+ r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
+ r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
+ r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
+ r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
+ r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
+ r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
+ r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
+ r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
+ r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
+ r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
+ r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
+ r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts", a.handleSaveDraft)
+ r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts/{id}", a.handleSaveDraft)
+ r.With(a.requirePermission(PermissionMailDrafts)).Delete("/mail/drafts/{id}", a.handleDeleteDraft)
+ r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
+ r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
+ r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
+ r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
+ r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
+ r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
+ r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
})
r.Group(func(r chi.Router) {
diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts
index 52e982c..1344502 100644
--- a/apps/web/src/lib/api-types.ts
+++ b/apps/web/src/lib/api-types.ts
@@ -1,4 +1,18 @@
export type PermissionKey =
+ | "mail.access"
+ | "mail.messages.read"
+ | "mail.messages.send"
+ | "mail.messages.drafts"
+ | "mail.messages.schedule"
+ | "mail.messages.organize"
+ | "mail.labels.manage"
+ | "mail.attachments.download"
+ | "mail.contacts.manage"
+ | "mail.signatures.manage"
+ | "mail.rules.manage"
+ | "mail.blocked_senders.manage"
+ | "mail.stats.view"
+ | "mail.mailboxes.apply"
| "admin.overview.view"
| "admin.users.view"
| "admin.users.create"
diff --git a/apps/web/src/lib/permissions.ts b/apps/web/src/lib/permissions.ts
index eb29f2f..6f7770f 100644
--- a/apps/web/src/lib/permissions.ts
+++ b/apps/web/src/lib/permissions.ts
@@ -1,5 +1,22 @@
import type { PermissionKey, User } from "@/lib/api-types"
+export const MAIL_PERMISSIONS: PermissionKey[] = [
+ "mail.access",
+ "mail.messages.read",
+ "mail.messages.send",
+ "mail.messages.drafts",
+ "mail.messages.schedule",
+ "mail.messages.organize",
+ "mail.labels.manage",
+ "mail.attachments.download",
+ "mail.contacts.manage",
+ "mail.signatures.manage",
+ "mail.rules.manage",
+ "mail.blocked_senders.manage",
+ "mail.stats.view",
+ "mail.mailboxes.apply",
+]
+
export const ADMIN_PERMISSIONS: PermissionKey[] = [
"admin.overview.view",
"admin.users.view",
@@ -51,3 +68,7 @@ export function hasAnyPermission(user: User | undefined | null, permissions: Per
export function hasAdminAccess(user: User | undefined | null) {
return hasAnyPermission(user, ADMIN_PERMISSIONS)
}
+
+export function hasMailAccess(user: User | undefined | null) {
+ return hasPermission(user, "mail.access")
+}
diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx
index be7a612..d491ef2 100644
--- a/apps/web/src/pages/mail.tsx
+++ b/apps/web/src/pages/mail.tsx
@@ -12,7 +12,7 @@ import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
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, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
+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, 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, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
@@ -47,6 +47,7 @@ import {
import { useMe } from "@/hooks/use-me"
import { useIsMobile } from "@/hooks/use-mobile"
import { useToast } from "@/hooks/use-toast"
+import { hasPermission } from "@/lib/permissions"
const folderIcons: Record = { inbox: , sent: , drafts: , archive: , spam: , trash: }
const folderLabels: Record = {
@@ -107,21 +108,31 @@ export function MailPage() {
const themeMountedRef = React.useRef(false)
const mailNotifyStateRef = React.useRef>({})
const mailAudioContextRef = React.useRef(null)
+ const user = me.data?.user
+ const canAccessMail = hasPermission(user, "mail.access")
+ const canReadMail = hasPermission(user, "mail.messages.read")
+ const canSendMail = hasPermission(user, "mail.messages.send")
+ const canManageDrafts = hasPermission(user, "mail.messages.drafts")
+ const canScheduleMail = hasPermission(user, "mail.messages.schedule")
+ const canOrganizeMail = hasPermission(user, "mail.messages.organize")
+ const canManageLabels = hasPermission(user, "mail.labels.manage")
+ const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
+ const canManageSignatures = hasPermission(user, "mail.signatures.manage")
- const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
+ const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const activeMailboxId = selectedMailbox?.id || ""
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
- const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId })
- const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
- const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
- const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId, refetchInterval: 30000 })
+ const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId && canReadMail })
+ const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
+ const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
+ const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 })
const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
const inboxProbe = useQuery({
queryKey: ["mail-notifications", activeMailboxId],
queryFn: () => api.messages("Inbox", "", "", activeMailboxId),
- enabled: !!activeMailboxId,
+ enabled: !!activeMailboxId && canReadMail,
refetchInterval: mailRefreshInterval,
refetchIntervalInBackground: true,
})
@@ -135,9 +146,9 @@ export function MailPage() {
},
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
- enabled: !!activeMailboxId && mailView !== "scheduled" && (mailView !== "label" || !!selectedLabelId),
+ enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && (mailView !== "label" || !!selectedLabelId),
})
- const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId })
+ const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail })
function updateCachedMessage(id: string, patch: Partial) {
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData | undefined) => {
@@ -364,7 +375,7 @@ export function MailPage() {
const visibleScheduledItems = scheduledQuery
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
: scheduledItems
- const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, scheduledCount)
+ const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail)
const labelItems = labels.data?.items || []
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
const viewTitle = mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
@@ -391,6 +402,7 @@ export function MailPage() {
])
}
async function runBulkAction(action: BulkAction) {
+ if (!canOrganizeMail) return
const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
if (ids.length === 0) return
if (action === "delete") {
@@ -438,10 +450,22 @@ export function MailPage() {
onConfirm: () => del.mutate(message.id),
})
}
- function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) }
- function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) }
- function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) }
+ function openCompose(draft?: ComposeDraft) {
+ if (draft?.isDraft && !canManageDrafts) return
+ if (!draft?.isDraft && !canSendMail) return
+ setComposeDraft(draft || { key: `new-${Date.now()}` })
+ setComposeOpen(true)
+ }
+ function openReply(message: MailMessage) {
+ if (!canSendMail) return
+ openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) })
+ }
+ function openForward(message: MailMessage) {
+ if (!canSendMail) return
+ openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) })
+ }
async function openDraft(message: MailMessage) {
+ if (!canManageDrafts) return
if (scheduledDraftIds.has(message.id)) {
toast({ title: "这封草稿已在待发送队列中", description: "请先取消定时发送,再继续编辑。" })
openScheduled()
@@ -516,7 +540,7 @@ export function MailPage() {
return
}
setSelectedId(messageId)
- if (message && !message.isRead) {
+ if (message && !message.isRead && canOrganizeMail) {
markRead.mutate({ id: message.id, read: true })
}
}
@@ -561,10 +585,12 @@ export function MailPage() {
)}
-
+ {canSendMail && (
+
+ )}
@@ -588,11 +614,11 @@ export function MailPage() {
{folders.isLoading && }
-
+ {(canReadMail || canManageLabels) &&
{!sidebarCollapsed && 标签}
- {labelItems.map((label) => (
+ {canReadMail && labelItems.map((label) => (
openLabel(label.id)}>
@@ -601,14 +627,16 @@ export function MailPage() {
))}
- {!sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && 暂无标签
}
-
- createLabel.mutate(name)} />
-
+ {canReadMail && !sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && 暂无标签
}
+ {canManageLabels && (
+
+ createLabel.mutate(name)} />
+
+ )}
{labels.isLoading && }
-
+ }
{!isMobile && (
@@ -630,9 +658,13 @@ export function MailPage() {
}
}
- const contentView = !mailboxList.isLoading && !hasMailboxes ? (
+ const contentView = !canAccessMail ? (
+
+ ) : !canReadMail ? (
+
+ ) : !mailboxList.isLoading && !hasMailboxes ? (
- ) : mailView === "scheduled" ? (
+ ) : mailView === "scheduled" && canScheduleMail ? (
cancelScheduledSend.mutate(item)}
/>
+ ) : mailView === "scheduled" ? (
+
) : isMobile || displayMode === "compact" ? (
removeLabel.mutate({ id: message.id, labelId })}
bulkPending={bulkPending}
onBulkAction={runBulkAction}
+ canSend={canSendMail}
+ canOrganize={canOrganizeMail}
+ canManageLabels={canManageLabels}
+ canDownloadAttachments={canDownloadAttachments}
/>
) : (
@@ -689,11 +727,11 @@ export function MailPage() {
{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}
- {selectedCountOnPage > 0 && }
+ {selectedCountOnPage > 0 && canOrganizeMail && }
{messages.isLoading && }
- {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
+ {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)}
{!messages.isLoading && visibleMessages.length === 0 && {emptyMessage}
}
{!messages.isLoading && hasMoreMessages && (
@@ -716,29 +754,31 @@ export function MailPage() {
{selected.subject}
-
-
- {selected.folder === "Archive" ? (
+ {canSendMail && }
+ {canSendMail && }
+ {canOrganizeMail && (selected.folder === "Archive" ? (
) : (
- )}
-
+ ))}
+ {canOrganizeMail && }
-
addLabel.mutate({ id: selected.id, label })}
- onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })}
- pending={addLabel.isPending || removeLabel.isPending}
- />
+ {canManageLabels && (
+ addLabel.mutate({ id: selected.id, label })}
+ onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })}
+ pending={addLabel.isPending || removeLabel.isPending}
+ />
+ )}
${escapeHtml(selected.bodyText || "")}`) }} />
- {selected.attachments && selected.attachments.length > 0 &&
}
+ {selected.attachments && selected.attachments.length > 0 &&
}
}
@@ -767,7 +807,7 @@ export function MailPage() {
{viewTitle}
-
+ {canSendMail && }
setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
@@ -796,7 +836,7 @@ export function MailPage() {
)}
{mailView !== "scheduled" && (
<>
-
+ {canOrganizeMail &&
}
@@ -824,7 +864,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"] }) }} />
[item.name, item]))
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 })
for (const item of folders) {
@@ -857,6 +897,7 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: , count: scheduledCount }
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
+ if (!includeScheduled) return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)]
return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)]
}
@@ -893,6 +934,23 @@ function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
)
}
+function PermissionEmptyState({ title, description, onOpenSettings }: { title: string; description: string; onOpenSettings: () => void }) {
+ return (
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+ )
+}
+
function ScheduledSendView({ compact, items, total, loading, query, cancelingId, onCancel }: { compact: boolean; items: ScheduledSend[]; total: number; loading: boolean; query: string; cancelingId: string; onCancel: (item: ScheduledSend) => void }) {
const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
return (
@@ -1023,6 +1081,10 @@ function CompactMailView({
onRemoveLabel,
bulkPending,
onBulkAction,
+ canSend,
+ canOrganize,
+ canManageLabels,
+ canDownloadAttachments,
}: {
title: string
icon?: React.ReactNode
@@ -1056,6 +1118,10 @@ function CompactMailView({
onRemoveLabel: (message: MailMessage, labelId: string) => void
bulkPending: boolean
onBulkAction: (action: BulkAction) => void
+ canSend: boolean
+ canOrganize: boolean
+ canManageLabels: boolean
+ canDownloadAttachments: boolean
}) {
const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1
const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined
@@ -1080,6 +1146,10 @@ function CompactMailView({
onToggleRead={onToggleRead}
onAddLabel={onAddLabel}
onRemoveLabel={onRemoveLabel}
+ canSend={canSend}
+ canOrganize={canOrganize}
+ canManageLabels={canManageLabels}
+ canDownloadAttachments={canDownloadAttachments}
/>
)
}
@@ -1098,7 +1168,7 @@ function CompactMailView({
{selectedIds.length > 0 ? (
<>
已选 {selectedIds.length} 封
-
+ {canOrganize && }
>
) : (
{messages.length} / {total} 封
@@ -1107,7 +1177,7 @@ function CompactMailView({
{loading && }
- {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)}
+ {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} canOrganize={canOrganize} />)}
{!loading && messages.length === 0 && {emptyMessage}
}
{!loading && hasMore && (
@@ -1138,6 +1208,10 @@ function CompactMessageDetail({
onToggleRead,
onAddLabel,
onRemoveLabel,
+ canSend,
+ canOrganize,
+ canManageLabels,
+ canDownloadAttachments,
}: {
selected?: MailMessage
loading: boolean
@@ -1155,6 +1229,10 @@ function CompactMessageDetail({
onToggleRead: (message: MailMessage) => void
onAddLabel: (message: MailMessage, label: MailLabel) => void
onRemoveLabel: (message: MailMessage, labelId: string) => void
+ canSend: boolean
+ canOrganize: boolean
+ canManageLabels: boolean
+ canDownloadAttachments: boolean
}) {
return (
@@ -1182,14 +1260,14 @@ function CompactMessageDetail({
onSelect(selected.id)}>编辑草稿
) : (
<>
-
onReply(selected)}>回复
-
onForward(selected)}>转发
-
onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}
-
onToggleRead(selected)}>{selected.isRead ? "标为未读" : "标为已读"}
-
onStar(selected)}>{selected.isStarred ? "取消星标" : "添加星标"}
+ {canSend &&
onReply(selected)}>回复}
+ {canSend &&
onForward(selected)}>转发}
+ {canOrganize &&
onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}}
+ {canOrganize &&
onToggleRead(selected)}>{selected.isRead ? "标为未读" : "标为已读"}}
+ {canOrganize &&
onStar(selected)}>{selected.isStarred ? "取消星标" : "添加星标"}}
>
)}
-
onDelete(selected)} className="text-destructive">删除
+ {canOrganize &&
onDelete(selected)} className="text-destructive">删除}
)}
@@ -1201,14 +1279,14 @@ function CompactMessageDetail({
) : (
<>
- {selected &&
}
- {selected &&
}
- {selected &&
}
- {selected &&
}
- {selected &&
}
+ {selected && canSend &&
}
+ {selected && canSend &&
}
+ {selected && canOrganize &&
}
+ {selected && canOrganize &&
}
+ {selected && canOrganize &&
}
>
)}
- {selected &&
}
+ {selected && canOrganize &&
}
@@ -1224,22 +1302,24 @@ function CompactMessageDetail({
{selected.subject}
- }
-
onAddLabel(selected, label)}
- onRemove={(labelId) => onRemoveLabel(selected, labelId)}
- pending={labelPending}
- />
+ {canManageLabels && (
+ onAddLabel(selected, label)}
+ onRemove={(labelId) => onRemoveLabel(selected, labelId)}
+ pending={labelPending}
+ />
+ )}
${escapeHtml(selected.bodyText || "")}`) }} />
- {selected.attachments && selected.attachments.length > 0 &&
}
+ {selected.attachments && selected.attachments.length > 0 &&
}
@@ -1248,7 +1328,7 @@ function CompactMessageDetail({
)
}
-function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) {
+function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar, canOrganize }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void; canOrganize: boolean }) {
const visibleLabels = (message.labels || []).slice(0, 2)
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
const senderName = senderDisplayName(message)
@@ -1266,9 +1346,9 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
{senderName}
{formatDate(message.receivedAt)}
- { event.stopPropagation(); onStar() }}>
+ {canOrganize && { event.stopPropagation(); onStar() }}>
-
+ }
@@ -1283,9 +1363,9 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
{formatDate(message.receivedAt)}
- { event.stopPropagation(); onStar() }}>
+ {canOrganize && { event.stopPropagation(); onStar() }}>
-
+ }
)
}
@@ -1525,6 +1605,7 @@ function MessageRow({
onCheckedChange,
onClick,
onStar,
+ canOrganize,
}: {
message: MailMessage
active: boolean
@@ -1533,6 +1614,7 @@ function MessageRow({
onCheckedChange: (checked: boolean) => void
onClick: () => void
onStar: () => void
+ canOrganize: boolean
}) {
const visibleLabels = (message.labels || []).slice(0, 2)
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
@@ -1550,7 +1632,7 @@ function MessageRow({
{senderName}
-
{ e.stopPropagation(); onStar() }}
>
-
+ }
{formatDate(message.receivedAt)}
@@ -1631,7 +1713,7 @@ function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pendin
)
}
-function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) {
+function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; 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([])
@@ -1651,7 +1733,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
const [showCc, setShowCc] = React.useState(Boolean(draft?.cc))
const [showBcc, setShowBcc] = React.useState(Boolean(draft?.bcc))
const [sendSeparately, setSendSeparately] = React.useState(false)
- const defaultSignature = useQuery({ queryKey: ["signature", "default", mailbox?.id], queryFn: () => api.defaultSignature(mailbox?.id), enabled: open && !!mailbox?.id })
+ const defaultSignature = useQuery({ queryKey: ["signature", "default", mailbox?.id], queryFn: () => api.defaultSignature(mailbox?.id), enabled: open && !!mailbox?.id && canManageSignatures })
const signatureText = defaultSignature.data?.signature?.content || ""
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))
@@ -1745,7 +1827,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
}, [files])
React.useEffect(() => {
- if (!open || sendStartedRef.current || !hasDraftContent) return
+ if (!open || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
const payloadKey = JSON.stringify({ ...composePayload, draftId })
if (payloadKey === lastSavedPayloadRef.current) return
const timer = window.setTimeout(async () => {
@@ -1766,7 +1848,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
}
}, 5000)
return () => window.clearTimeout(timer)
- }, [open, hasDraftContent, composePayload, draftId, qc])
+ }, [open, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
function buildSendWarnings(attachmentsCount: number) {
const warnings: string[] = []
@@ -1791,6 +1873,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
}
async function prepareSend() {
+ if (!canSend) return
if (!mailbox) return
const attachments = await Promise.all(files.map(fileToAttachment))
const to = splitEmails(toValue)
@@ -1824,6 +1907,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
await prepareSend()
}
async function scheduleAt(sendAt: string) {
+ if (!canSchedule) return
if (!mailbox) {
toast({ title: "请选择发件邮箱" })
return
@@ -1913,8 +1997,8 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
onOpenChange(false)}>取消
- setScheduleDialogOpen(true)}>定时
- {send.isPending ? "发送中..." : "发送"}
+ {canSchedule && setScheduleDialogOpen(true)}>定时}
+ {canSend && {send.isPending ? "发送中..." : "发送"}}
diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx
index 07be592..7eea431 100644
--- a/apps/web/src/pages/profile.tsx
+++ b/apps/web/src/pages/profile.tsx
@@ -12,6 +12,7 @@ import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { useIsMobile } from "@/hooks/use-mobile"
import { validatePasswordConfirm } from "@/lib/validation"
+import { hasPermission } from "@/lib/permissions"
import { Button } from "@/components/ui/button"
import { PasswordInput } from "@/components/ui/password-input"
import { Input } from "@/components/ui/input"
@@ -67,19 +68,41 @@ export function ProfilePage() {
const themeMountedRef = React.useRef(false)
const rawTab = params.get("tab") as Tab | null
- const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile"
const user = me.data?.user
- const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
- const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
+ const canAccessMail = hasPermission(user, "mail.access")
+ const canReadMail = hasPermission(user, "mail.messages.read")
+ const canOrganizeMail = hasPermission(user, "mail.messages.organize")
+ const canManageLabels = hasPermission(user, "mail.labels.manage")
+ const canManageContacts = hasPermission(user, "mail.contacts.manage")
+ const canManageSignatures = hasPermission(user, "mail.signatures.manage")
+ const canManageRules = hasPermission(user, "mail.rules.manage")
+ const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
+ const canViewStats = hasPermission(user, "mail.stats.view")
+ const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
+ const visibleTabKeys = tabKeys.filter((key) => {
+ if (key === "profile") return true
+ if (key === "mailboxes") return canAccessMail || canApplyMailbox
+ if (key === "clients") return canAccessMail
+ if (key === "signatures") return canManageSignatures
+ if (key === "contacts") return canManageContacts
+ if (key === "cleanup") return canOrganizeMail
+ if (key === "rules") return canManageRules
+ if (key === "blocked") return canManageBlocked
+ if (key === "stats") return canViewStats
+ return false
+ })
+ const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile"
+ const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
+ const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
- const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
- const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures })
- const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
- const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
+ const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts })
+ const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures })
+ const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules })
+ const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const activeMailboxId = selectedMailbox?.id || ""
- const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
- const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
+ const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) })
+ const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
const profile = useMutation({
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
@@ -191,7 +214,11 @@ export function ProfilePage() {
const logout = useLogout()
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
- function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }); setMobileSidebarOpen(false) }
+ function setTab(next: Tab) {
+ const visibleNext = visibleTabKeys.includes(next) ? next : "profile"
+ setParams(visibleNext === "profile" ? {} : { tab: visibleNext })
+ setMobileSidebarOpen(false)
+ }
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
if (me.isLoading) return 加载中...
if (me.isError || !user) return 登录状态已失效
@@ -205,7 +232,7 @@ export function ProfilePage() {
{!sidebarCollapsed && 个人中心}
- {tabKeys.map((key) => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && {tabs[key].label}})}
+ {visibleTabKeys.map((key) => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && {tabs[key].label}})}
@@ -264,19 +291,19 @@ export function ProfilePage() {
)
function renderTab() {
- if (tab === "mailboxes") return { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
+ if (tab === "mailboxes") return { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
if (tab === "clients") return
if (tab === "signatures") return createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
- if (tab === "cleanup") return cleanup.mutate(target)} />
+ if (tab === "cleanup") return cleanup.mutate(target)} />
if (tab === "rules") return createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
- if (tab === "blocked") return f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
+ if (tab === "blocked") return f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "stats") return stats.refetch()} />
- return
+ return
}
}
-function ProfileOverview({ user, profile, password, passwordFormRef, stats, 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; 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 }; 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 (
@@ -424,7 +451,7 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
-
+ {showStats && }
)
}
@@ -718,7 +745,7 @@ function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }
)
}
-function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
+function CleanupSection({ mailbox, stats, showStats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; showStats: boolean; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
const [pendingConfirm, setPendingConfirm] = React.useState(null)
function confirmCleanup(target: "empty-trash" | "empty-spam" | "archive-read-inbox", title: string, destructive = false) {
setPendingConfirm({
@@ -731,7 +758,7 @@ function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mail
}
return (
-
+ {showStats && }
清理当前邮箱