From 0b982a5ff4559f0966e6e10fdaeed6b4f5f4a12d Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Mon, 22 Jun 2026 15:54:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E9=87=8D=E6=9E=84=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E6=9D=83=E9=99=90=E4=BD=93=E7=B3=BB=E5=B9=B6=E7=BB=86?= =?UTF-8?q?=E5=8C=96=E5=89=8D=E5=8F=B0=E8=83=BD=E5=8A=9B=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增邮箱前台权限与默认普通用户权限迁移,拆分读信、发信、草稿、定时、整理、标签、附件、联系人等能力。 - 收紧后端路由与处理逻辑,按权限限制邮箱前台、个人中心和定时发送相关接口。 - 前端根据权限动态隐藏或禁用邮件、个人中心中的对应功能入口与操作。 - 补充权限迁移与访问控制测试,覆盖普通用户邮箱前台、发信和定时发送的权限校验。 --- apps/api/internal/app/app_test.go | 105 ++++++++++- apps/api/internal/app/mail_handlers.go | 7 +- apps/api/internal/app/permissions.go | 118 +++++++++++- apps/api/internal/app/router_auth.go | 80 ++++---- apps/web/src/lib/api-types.ts | 14 ++ apps/web/src/lib/permissions.ts | 21 +++ apps/web/src/pages/mail.tsx | 250 +++++++++++++++++-------- apps/web/src/pages/profile.tsx | 65 +++++-- 8 files changed, 507 insertions(+), 153 deletions(-) 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.map((a) => {a.filename}{formatBytes(a.sizeBytes)})}
} + {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
} @@ -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.map((a) => {a.filename}{formatBytes(a.sizeBytes)})}
} + {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
@@ -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)} - + }
@@ -1283,9 +1363,9 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
{formatDate(message.receivedAt)}
- + } ) } @@ -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}
- + }
{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 - - + {canSchedule && } + {canSend && } 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 && } 清理当前邮箱