feat(mail): 重构邮箱权限体系并细化前台能力控制
- 新增邮箱前台权限与默认普通用户权限迁移,拆分读信、发信、草稿、定时、整理、标签、附件、联系人等能力。 - 收紧后端路由与处理逻辑,按权限限制邮箱前台、个人中心和定时发送相关接口。 - 前端根据权限动态隐藏或禁用邮件、个人中心中的对应功能入口与操作。 - 补充权限迁移与访问控制测试,覆盖普通用户邮箱前台、发信和定时发送的权限校验。
This commit is contained in:
@@ -170,6 +170,19 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
|
|||||||
return mailbox
|
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 {
|
func systemSettingsPayload(settings SystemSettings) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"publicHostname": settings.PublicHostname,
|
"publicHostname": settings.PublicHostname,
|
||||||
@@ -938,14 +951,7 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
|||||||
}, &errBody); code != http.StatusForbidden {
|
}, &errBody); code != http.StatusForbidden {
|
||||||
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
||||||
}
|
}
|
||||||
var regularGroup PermissionGroup
|
regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
|
||||||
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)
|
|
||||||
}
|
|
||||||
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
||||||
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup)
|
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": "<p>body</p>",
|
||||||
|
}
|
||||||
|
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": "<p>body</p>",
|
||||||
|
"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) {
|
func TestMaildirSyncImportsRFC822(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -1377,3 +1452,17 @@ func containsString(items []string, needle string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "message not found")
|
respondError(w, http.StatusNotFound, "message not found")
|
||||||
return
|
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)
|
_, _ = 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
|
msg.IsRead = true
|
||||||
}
|
}
|
||||||
@@ -770,6 +770,11 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
|
|||||||
a.markScheduledSendFailed(ctx, id, "mailbox not found")
|
a.markScheduledSendFailed(ctx, id, "mailbox not found")
|
||||||
return
|
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}
|
compose := mailComposeInput{MailboxID: payload.MailboxID, To: payload.To, CC: payload.CC, BCC: payload.BCC, Subject: payload.Subject, Text: payload.Text, HTML: payload.HTML, Attachments: payload.Attachments}
|
||||||
if _, err := a.sendMailNow(ctx, mb, compose); err != nil {
|
if _, err := a.sendMailNow(ctx, mb, compose); err != nil {
|
||||||
a.markScheduledSendFailed(ctx, id, err.Error())
|
a.markScheduledSendFailed(ctx, id, err.Error())
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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"
|
PermissionAdminOverview = "admin.overview.view"
|
||||||
|
|
||||||
PermissionUsersView = "admin.users.view"
|
PermissionUsersView = "admin.users.view"
|
||||||
@@ -65,6 +82,8 @@ const (
|
|||||||
PermissionSystemSettings = PermissionSettingsUpdate
|
PermissionSystemSettings = PermissionSettingsUpdate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const regularUserPermissionMigrationKey = "permission_groups.regular_mail_permissions_v1"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PermissionGroupSuperAdmin = "pg_super_admin"
|
PermissionGroupSuperAdmin = "pg_super_admin"
|
||||||
PermissionGroupRegular = "pg_regular_user"
|
PermissionGroupRegular = "pg_regular_user"
|
||||||
@@ -117,6 +136,13 @@ type PermissionGroup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var legacyPermissionExpansions = map[string][]string{
|
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.overview": {PermissionAdminOverview},
|
||||||
"admin.users": {PermissionUsersView, PermissionUsersCreate, PermissionUsersUpdate, PermissionUsersDelete, PermissionUsersResetPassword, PermissionGroupsView},
|
"admin.users": {PermissionUsersView, PermissionUsersCreate, PermissionUsersUpdate, PermissionUsersDelete, PermissionUsersResetPassword, PermissionGroupsView},
|
||||||
"admin.permission_groups": {PermissionGroupsView, PermissionGroupsCreate, PermissionGroupsUpdate, PermissionGroupsDelete},
|
"admin.permission_groups": {PermissionGroupsView, PermissionGroupsCreate, PermissionGroupsUpdate, PermissionGroupsDelete},
|
||||||
@@ -129,6 +155,21 @@ var legacyPermissionExpansions = map[string][]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var permissionCatalogItems = []PermissionInfo{
|
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: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
||||||
|
|
||||||
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
|
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
|
||||||
@@ -186,6 +227,16 @@ func allPermissionKeys() []string {
|
|||||||
return out
|
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 {
|
func permissionSet() map[string]bool {
|
||||||
out := map[string]bool{}
|
out := map[string]bool{}
|
||||||
for _, item := range permissionCatalogItems {
|
for _, item := range permissionCatalogItems {
|
||||||
@@ -269,12 +320,31 @@ func defaultPermissionGroups() []PermissionGroup {
|
|||||||
ID: PermissionGroupRegular,
|
ID: PermissionGroupRegular,
|
||||||
Name: "普通用户",
|
Name: "普通用户",
|
||||||
Description: "仅可使用自己的邮箱功能,不包含后台权限。",
|
Description: "仅可使用自己的邮箱功能,不包含后台权限。",
|
||||||
Permissions: []string{},
|
Permissions: regularUserDefaultPermissions(),
|
||||||
System: true,
|
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 {
|
func fixedPermissionGroupIDs() map[string]bool {
|
||||||
out := map[string]bool{}
|
out := map[string]bool{}
|
||||||
for _, group := range defaultPermissionGroups() {
|
for _, group := range defaultPermissionGroups() {
|
||||||
@@ -331,9 +401,53 @@ func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
|||||||
if err := a.cleanupLegacyDefaultPermissionGroups(ctx); err != nil {
|
if err := a.cleanupLegacyDefaultPermissionGroups(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.ensureRegularUserMailPermissions(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
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 {
|
func (a *App) cleanupLegacyDefaultPermissionGroups(ctx context.Context) error {
|
||||||
for _, groupID := range legacyDefaultPermissionGroupIDs {
|
for _, groupID := range legacyDefaultPermissionGroupIDs {
|
||||||
var userCount int
|
var userCount int
|
||||||
@@ -543,7 +657,7 @@ func userHasAnyPermission(user *User, permissions ...string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func userHasAdminAccess(user *User) bool {
|
func userHasAdminAccess(user *User) bool {
|
||||||
return userHasAnyPermission(user, allPermissionKeys()...)
|
return userHasAnyPermission(user, adminPermissionKeys()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) requireAdminAccess(next http.Handler) http.Handler {
|
func (a *App) requireAdminAccess(next http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -36,53 +36,53 @@ func (a *App) Router() http.Handler {
|
|||||||
r.With(a.requireAuth).Get("/me", a.handleMe)
|
r.With(a.requireAuth).Get("/me", a.handleMe)
|
||||||
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
|
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
|
||||||
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
|
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
|
||||||
r.With(a.requireAuth).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
|
||||||
r.With(a.requireAuth).Post("/me/mailboxes/apply", a.handleApplyMailbox)
|
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/setup", a.handleTwoFactorSetup)
|
||||||
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
|
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
|
||||||
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
|
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
|
||||||
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Get("/me/contacts", a.handleListContacts)
|
||||||
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Post("/me/contacts", a.handleCreateContact)
|
||||||
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
||||||
r.With(a.requireAuth).Get("/me/signatures", a.handleListSignatures)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures", a.handleListSignatures)
|
||||||
r.With(a.requireAuth).Post("/me/signatures", a.handleCreateSignature)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures", a.handleCreateSignature)
|
||||||
r.With(a.requireAuth).Post("/me/signatures/{id}", a.handleUpdateSignature)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}", a.handleUpdateSignature)
|
||||||
r.With(a.requireAuth).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
|
||||||
r.With(a.requireAuth).Delete("/me/signatures/{id}", a.handleDeleteSignature)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Delete("/me/signatures/{id}", a.handleDeleteSignature)
|
||||||
r.With(a.requireAuth).Get("/me/signatures/default", a.handleDefaultSignature)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures/default", a.handleDefaultSignature)
|
||||||
r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Get("/me/rules", a.handleListRules)
|
||||||
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules", a.handleCreateRule)
|
||||||
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Delete("/me/rules/{id}", a.handleDeleteRule)
|
||||||
r.With(a.requireAuth).Get("/me/blocked-senders", a.handleListBlockedSenders)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Get("/me/blocked-senders", a.handleListBlockedSenders)
|
||||||
r.With(a.requireAuth).Post("/me/blocked-senders", a.handleCreateBlockedSender)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Post("/me/blocked-senders", a.handleCreateBlockedSender)
|
||||||
r.With(a.requireAuth).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
||||||
r.With(a.requireAuth).Get("/me/stats", a.handleMailStats)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
|
||||||
r.With(a.requireAuth).Post("/me/cleanup", a.handleMailCleanup)
|
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
|
||||||
r.With(a.requireAuth).Get("/events", a.handleEvents)
|
r.With(a.requireAuth).Get("/events", a.handleEvents)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(a.requireAuth)
|
r.Use(a.requireAuth)
|
||||||
r.Get("/mail/mailboxes", a.handleMyMailboxes)
|
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
|
||||||
r.Get("/mail/folders", a.handleMailFolders)
|
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
|
||||||
r.Get("/mail/labels", a.handleMailLabels)
|
r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
|
||||||
r.Post("/mail/labels", a.handleCreateMailLabel)
|
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
|
||||||
r.Get("/mail/messages", a.handleMailMessages)
|
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
|
||||||
r.Get("/mail/starred", a.handleStarredMessages)
|
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
||||||
r.Get("/mail/messages/{id}", a.handleMailMessage)
|
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
||||||
r.Post("/mail/send", a.handleMailSend)
|
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||||
r.Get("/mail/scheduled-sends", a.handleScheduledSends)
|
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||||
r.Post("/mail/schedule-send", a.handleScheduleSend)
|
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
||||||
r.Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||||
r.Post("/mail/drafts", a.handleSaveDraft)
|
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts", a.handleSaveDraft)
|
||||||
r.Post("/mail/drafts/{id}", a.handleSaveDraft)
|
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts/{id}", a.handleSaveDraft)
|
||||||
r.Delete("/mail/drafts/{id}", a.handleDeleteDraft)
|
r.With(a.requirePermission(PermissionMailDrafts)).Delete("/mail/drafts/{id}", a.handleDeleteDraft)
|
||||||
r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
|
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
|
||||||
r.Post("/mail/messages/{id}/star", a.handleStar)
|
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
|
||||||
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||||
r.Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
||||||
r.Post("/mail/messages/{id}/move", a.handleMove)
|
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
|
||||||
r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||||
r.Get("/mail/attachments/{id}", a.handleAttachment)
|
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
export type PermissionKey =
|
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.overview.view"
|
||||||
| "admin.users.view"
|
| "admin.users.view"
|
||||||
| "admin.users.create"
|
| "admin.users.create"
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import type { PermissionKey, User } from "@/lib/api-types"
|
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[] = [
|
export const ADMIN_PERMISSIONS: PermissionKey[] = [
|
||||||
"admin.overview.view",
|
"admin.overview.view",
|
||||||
"admin.users.view",
|
"admin.users.view",
|
||||||
@@ -51,3 +68,7 @@ export function hasAnyPermission(user: User | undefined | null, permissions: Per
|
|||||||
export function hasAdminAccess(user: User | undefined | null) {
|
export function hasAdminAccess(user: User | undefined | null) {
|
||||||
return hasAnyPermission(user, ADMIN_PERMISSIONS)
|
return hasAnyPermission(user, ADMIN_PERMISSIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasMailAccess(user: User | undefined | null) {
|
||||||
|
return hasPermission(user, "mail.access")
|
||||||
|
}
|
||||||
|
|||||||
+146
-62
@@ -12,7 +12,7 @@ import Placeholder from "@tiptap/extension-placeholder"
|
|||||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
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 { api, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api"
|
||||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime } from "@/lib/utils"
|
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime } from "@/lib/utils"
|
||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { hasPermission } from "@/lib/permissions"
|
||||||
|
|
||||||
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Trash2 className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
|
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Trash2 className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
|
||||||
const folderLabels: Record<string, string> = {
|
const folderLabels: Record<string, string> = {
|
||||||
@@ -107,21 +108,31 @@ export function MailPage() {
|
|||||||
const themeMountedRef = React.useRef(false)
|
const themeMountedRef = React.useRef(false)
|
||||||
const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({})
|
const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({})
|
||||||
const mailAudioContextRef = React.useRef<AudioContext | null>(null)
|
const mailAudioContextRef = React.useRef<AudioContext | null>(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 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 selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
|
||||||
const activeMailboxId = selectedMailbox?.id || ""
|
const activeMailboxId = selectedMailbox?.id || ""
|
||||||
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
|
const hasMailboxes = (mailboxList.data?.items.length || 0) > 0
|
||||||
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId })
|
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 })
|
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 })
|
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, refetchInterval: 30000 })
|
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 mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
|
||||||
const inboxProbe = useQuery({
|
const inboxProbe = useQuery({
|
||||||
queryKey: ["mail-notifications", activeMailboxId],
|
queryKey: ["mail-notifications", activeMailboxId],
|
||||||
queryFn: () => api.messages("Inbox", "", "", activeMailboxId),
|
queryFn: () => api.messages("Inbox", "", "", activeMailboxId),
|
||||||
enabled: !!activeMailboxId,
|
enabled: !!activeMailboxId && canReadMail,
|
||||||
refetchInterval: mailRefreshInterval,
|
refetchInterval: mailRefreshInterval,
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: true,
|
||||||
})
|
})
|
||||||
@@ -135,9 +146,9 @@ export function MailPage() {
|
|||||||
},
|
},
|
||||||
initialPageParam: "",
|
initialPageParam: "",
|
||||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
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<MailMessage>) {
|
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||||
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
|
qc.setQueryData(["message", id], (current: MailMessage | undefined) => current ? { ...current, ...patch } : current)
|
||||||
qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => {
|
qc.setQueriesData({ queryKey: ["messages"] }, (current: InfiniteData<MailListResponse> | undefined) => {
|
||||||
@@ -364,7 +375,7 @@ export function MailPage() {
|
|||||||
const visibleScheduledItems = scheduledQuery
|
const visibleScheduledItems = scheduledQuery
|
||||||
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
|
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
|
||||||
: scheduledItems
|
: 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 labelItems = labels.data?.items || []
|
||||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
||||||
const viewTitle = mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
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) {
|
async function runBulkAction(action: BulkAction) {
|
||||||
|
if (!canOrganizeMail) return
|
||||||
const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
|
const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
if (action === "delete") {
|
if (action === "delete") {
|
||||||
@@ -438,10 +450,22 @@ export function MailPage() {
|
|||||||
onConfirm: () => del.mutate(message.id),
|
onConfirm: () => del.mutate(message.id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) }
|
function openCompose(draft?: ComposeDraft) {
|
||||||
function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) }
|
if (draft?.isDraft && !canManageDrafts) return
|
||||||
function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) }
|
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) {
|
async function openDraft(message: MailMessage) {
|
||||||
|
if (!canManageDrafts) return
|
||||||
if (scheduledDraftIds.has(message.id)) {
|
if (scheduledDraftIds.has(message.id)) {
|
||||||
toast({ title: "这封草稿已在待发送队列中", description: "请先取消定时发送,再继续编辑。" })
|
toast({ title: "这封草稿已在待发送队列中", description: "请先取消定时发送,再继续编辑。" })
|
||||||
openScheduled()
|
openScheduled()
|
||||||
@@ -516,7 +540,7 @@ export function MailPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSelectedId(messageId)
|
setSelectedId(messageId)
|
||||||
if (message && !message.isRead) {
|
if (message && !message.isRead && canOrganizeMail) {
|
||||||
markRead.mutate({ id: message.id, read: true })
|
markRead.mutate({ id: message.id, read: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -561,10 +585,12 @@ export function MailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{canSendMail && (
|
||||||
<Button className={cn("mt-2 h-10 w-full rounded-md text-sm", sidebarCollapsed && "px-0")} size={sidebarCollapsed ? "icon" : "default"} onClick={() => openCompose()} disabled={!selectedMailbox}>
|
<Button className={cn("mt-2 h-10 w-full rounded-md text-sm", sidebarCollapsed && "px-0")} size={sidebarCollapsed ? "icon" : "default"} onClick={() => openCompose()} disabled={!selectedMailbox}>
|
||||||
<PencilLine className="h-4 w-4" />
|
<PencilLine className="h-4 w-4" />
|
||||||
{!sidebarCollapsed && <span>写邮件</span>}
|
{!sidebarCollapsed && <span>写邮件</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
@@ -588,11 +614,11 @@ export function MailPage() {
|
|||||||
{folders.isLoading && <FolderSkeleton />}
|
{folders.isLoading && <FolderSkeleton />}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<SidebarGroup>
|
{(canReadMail || canManageLabels) && <SidebarGroup>
|
||||||
{!sidebarCollapsed && <SidebarGroupLabel>标签</SidebarGroupLabel>}
|
{!sidebarCollapsed && <SidebarGroupLabel>标签</SidebarGroupLabel>}
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{labelItems.map((label) => (
|
{canReadMail && labelItems.map((label) => (
|
||||||
<SidebarMenuItem key={label.id}>
|
<SidebarMenuItem key={label.id}>
|
||||||
<SidebarMenuButton isActive={mailView === "label" && selectedLabelId === label.id} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => openLabel(label.id)}>
|
<SidebarMenuButton isActive={mailView === "label" && selectedLabelId === label.id} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => openLabel(label.id)}>
|
||||||
<Tag className="h-4 w-4" style={{ color: label.color }} />
|
<Tag className="h-4 w-4" style={{ color: label.color }} />
|
||||||
@@ -601,14 +627,16 @@ export function MailPage() {
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
{!sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground">暂无标签</div>}
|
{canReadMail && !sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground">暂无标签</div>}
|
||||||
|
{canManageLabels && (
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => createLabel.mutate(name)} />
|
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => createLabel.mutate(name)} />
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{labels.isLoading && <FolderSkeleton />}
|
{labels.isLoading && <FolderSkeleton />}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex justify-center" : "")}>
|
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex justify-center" : "")}>
|
||||||
@@ -630,9 +658,13 @@ export function MailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentView = !mailboxList.isLoading && !hasMailboxes ? (
|
const contentView = !canAccessMail ? (
|
||||||
|
<PermissionEmptyState title="无邮箱前台权限" description="当前账号未开启邮箱前台访问权限。" onOpenSettings={openSettings} />
|
||||||
|
) : !canReadMail ? (
|
||||||
|
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||||
|
) : !mailboxList.isLoading && !hasMailboxes ? (
|
||||||
<NoMailboxState onOpenSettings={openSettings} />
|
<NoMailboxState onOpenSettings={openSettings} />
|
||||||
) : mailView === "scheduled" ? (
|
) : mailView === "scheduled" && canScheduleMail ? (
|
||||||
<ScheduledSendView
|
<ScheduledSendView
|
||||||
compact={isMobile || displayMode === "compact"}
|
compact={isMobile || displayMode === "compact"}
|
||||||
items={visibleScheduledItems}
|
items={visibleScheduledItems}
|
||||||
@@ -642,6 +674,8 @@ export function MailPage() {
|
|||||||
cancelingId={cancelingScheduledId}
|
cancelingId={cancelingScheduledId}
|
||||||
onCancel={(item) => cancelScheduledSend.mutate(item)}
|
onCancel={(item) => cancelScheduledSend.mutate(item)}
|
||||||
/>
|
/>
|
||||||
|
) : mailView === "scheduled" ? (
|
||||||
|
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
||||||
) : isMobile || displayMode === "compact" ? (
|
) : isMobile || displayMode === "compact" ? (
|
||||||
<CompactMailView
|
<CompactMailView
|
||||||
title={viewTitle}
|
title={viewTitle}
|
||||||
@@ -676,6 +710,10 @@ export function MailPage() {
|
|||||||
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
|
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
|
||||||
bulkPending={bulkPending}
|
bulkPending={bulkPending}
|
||||||
onBulkAction={runBulkAction}
|
onBulkAction={runBulkAction}
|
||||||
|
canSend={canSendMail}
|
||||||
|
canOrganize={canOrganizeMail}
|
||||||
|
canManageLabels={canManageLabels}
|
||||||
|
canDownloadAttachments={canDownloadAttachments}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
||||||
@@ -689,11 +727,11 @@ export function MailPage() {
|
|||||||
<div className="text-xs text-muted-foreground">{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}</div>
|
<div className="text-xs text-muted-foreground">{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedCountOnPage > 0 && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />}
|
{selectedCountOnPage > 0 && canOrganizeMail && <BulkActionMenu pending={bulkPending} onAction={runBulkAction} />}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
{messages.isLoading && <MessageSkeleton />}
|
{messages.isLoading && <MessageSkeleton />}
|
||||||
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
|
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)}
|
||||||
{!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
|
{!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
|
||||||
{!messages.isLoading && hasMoreMessages && (
|
{!messages.isLoading && hasMoreMessages && (
|
||||||
<div className="border-b p-4 text-center">
|
<div className="border-b p-4 text-center">
|
||||||
@@ -716,17 +754,18 @@ export function MailPage() {
|
|||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<h2 className="text-xl font-semibold">{selected.subject}</h2>
|
<h2 className="text-xl font-semibold">{selected.subject}</h2>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" />回复</Button>
|
{canSendMail && <Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
||||||
<Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" />转发</Button>
|
{canSendMail && <Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
||||||
{selected.folder === "Archive" ? (
|
{canOrganizeMail && (selected.folder === "Archive" ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}>取消归档</Button>
|
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}>取消归档</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}>归档</Button>
|
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}>归档</Button>
|
||||||
)}
|
))}
|
||||||
<Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>删除</Button>
|
{canOrganizeMail && <Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>删除</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MessageMetaPanel message={selected} />
|
<MessageMetaPanel message={selected} />
|
||||||
|
{canManageLabels && (
|
||||||
<MessageLabels
|
<MessageLabels
|
||||||
messageLabels={selected.labels || []}
|
messageLabels={selected.labels || []}
|
||||||
availableLabels={labelItems}
|
availableLabels={labelItems}
|
||||||
@@ -734,11 +773,12 @@ export function MailPage() {
|
|||||||
onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })}
|
onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })}
|
||||||
pending={addLabel.isPending || removeLabel.isPending}
|
pending={addLabel.isPending || removeLabel.isPending}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
|
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
|
||||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>}
|
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex items-center justify-between rounded-md border p-3 text-sm text-muted-foreground" key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>}
|
</div>}
|
||||||
@@ -767,7 +807,7 @@ export function MailPage() {
|
|||||||
<RefreshCcw className={cn("h-4 w-4", (refreshing || autoRefreshing) && "animate-spin")} />
|
<RefreshCcw className={cn("h-4 w-4", (refreshing || autoRefreshing) && "animate-spin")} />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="min-w-0 flex-1 text-sm font-semibold">{viewTitle}</div>
|
<div className="min-w-0 flex-1 text-sm font-semibold">{viewTitle}</div>
|
||||||
<Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>
|
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
||||||
<div className="relative basis-full">
|
<div className="relative basis-full">
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
|
||||||
@@ -796,7 +836,7 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
{mailView !== "scheduled" && (
|
{mailView !== "scheduled" && (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>
|
{canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm"><SlidersHorizontal className="h-4 w-4" />{filterLabels[mailFilter]}</Button>
|
<Button variant="outline" size="sm"><SlidersHorizontal className="h-4 w-4" />{filterLabels[mailFilter]}</Button>
|
||||||
@@ -824,7 +864,7 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
||||||
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} onOpenChange={(open) => { 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"] }) }} />
|
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { 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"] }) }} />
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!pendingConfirm}
|
open={!!pendingConfirm}
|
||||||
title={pendingConfirm?.title || ""}
|
title={pendingConfirm?.title || ""}
|
||||||
@@ -839,7 +879,7 @@ export function MailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number): MailMenuItem[] {
|
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean): MailMenuItem[] {
|
||||||
const byName = new Map(folders.map((item) => [item.name, item]))
|
const byName = new Map(folders.map((item) => [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 })
|
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) {
|
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: <Clock3 className="h-4 w-4" />, count: scheduledCount }
|
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount }
|
||||||
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
|
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
|
||||||
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
|
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)]
|
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 (
|
||||||
|
<div className="grid min-h-0 flex-1 place-items-center p-6">
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-dashed p-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 grid size-12 place-items-center rounded-full bg-muted">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold">{title}</div>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">{description}</div>
|
||||||
|
<Button className="mt-5" onClick={onOpenSettings}>
|
||||||
|
<Settings className="h-4 w-4" />前往个人中心
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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 }) {
|
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() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
|
const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
|
||||||
return (
|
return (
|
||||||
@@ -1023,6 +1081,10 @@ function CompactMailView({
|
|||||||
onRemoveLabel,
|
onRemoveLabel,
|
||||||
bulkPending,
|
bulkPending,
|
||||||
onBulkAction,
|
onBulkAction,
|
||||||
|
canSend,
|
||||||
|
canOrganize,
|
||||||
|
canManageLabels,
|
||||||
|
canDownloadAttachments,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactNode
|
||||||
@@ -1056,6 +1118,10 @@ function CompactMailView({
|
|||||||
onRemoveLabel: (message: MailMessage, labelId: string) => void
|
onRemoveLabel: (message: MailMessage, labelId: string) => void
|
||||||
bulkPending: boolean
|
bulkPending: boolean
|
||||||
onBulkAction: (action: BulkAction) => void
|
onBulkAction: (action: BulkAction) => void
|
||||||
|
canSend: boolean
|
||||||
|
canOrganize: boolean
|
||||||
|
canManageLabels: boolean
|
||||||
|
canDownloadAttachments: boolean
|
||||||
}) {
|
}) {
|
||||||
const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1
|
const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1
|
||||||
const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined
|
const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined
|
||||||
@@ -1080,6 +1146,10 @@ function CompactMailView({
|
|||||||
onToggleRead={onToggleRead}
|
onToggleRead={onToggleRead}
|
||||||
onAddLabel={onAddLabel}
|
onAddLabel={onAddLabel}
|
||||||
onRemoveLabel={onRemoveLabel}
|
onRemoveLabel={onRemoveLabel}
|
||||||
|
canSend={canSend}
|
||||||
|
canOrganize={canOrganize}
|
||||||
|
canManageLabels={canManageLabels}
|
||||||
|
canDownloadAttachments={canDownloadAttachments}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1098,7 +1168,7 @@ function CompactMailView({
|
|||||||
{selectedIds.length > 0 ? (
|
{selectedIds.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden text-sm text-muted-foreground min-[380px]:inline">已选 {selectedIds.length} 封</span>
|
<span className="hidden text-sm text-muted-foreground min-[380px]:inline">已选 {selectedIds.length} 封</span>
|
||||||
<BulkActionMenu pending={bulkPending} onAction={onBulkAction} />
|
{canOrganize && <BulkActionMenu pending={bulkPending} onAction={onBulkAction} />}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">{messages.length} / {total} 封</div>
|
<div className="text-sm text-muted-foreground">{messages.length} / {total} 封</div>
|
||||||
@@ -1107,7 +1177,7 @@ function CompactMailView({
|
|||||||
</div>
|
</div>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
{loading && <MessageSkeleton />}
|
{loading && <MessageSkeleton />}
|
||||||
{messages.map((message) => <CompactMessageRow key={message.id} message={message} active={selectedId === message.id} checked={selectedIds.includes(message.id)} scheduled={scheduledDraftIds.has(message.id)} onCheckedChange={(checked) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)}
|
{messages.map((message) => <CompactMessageRow key={message.id} message={message} active={selectedId === message.id} checked={selectedIds.includes(message.id)} scheduled={scheduledDraftIds.has(message.id)} onCheckedChange={(checked) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} canOrganize={canOrganize} />)}
|
||||||
{!loading && messages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
|
{!loading && messages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
|
||||||
{!loading && hasMore && (
|
{!loading && hasMore && (
|
||||||
<div className="border-b p-4 text-center">
|
<div className="border-b p-4 text-center">
|
||||||
@@ -1138,6 +1208,10 @@ function CompactMessageDetail({
|
|||||||
onToggleRead,
|
onToggleRead,
|
||||||
onAddLabel,
|
onAddLabel,
|
||||||
onRemoveLabel,
|
onRemoveLabel,
|
||||||
|
canSend,
|
||||||
|
canOrganize,
|
||||||
|
canManageLabels,
|
||||||
|
canDownloadAttachments,
|
||||||
}: {
|
}: {
|
||||||
selected?: MailMessage
|
selected?: MailMessage
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -1155,6 +1229,10 @@ function CompactMessageDetail({
|
|||||||
onToggleRead: (message: MailMessage) => void
|
onToggleRead: (message: MailMessage) => void
|
||||||
onAddLabel: (message: MailMessage, label: MailLabel) => void
|
onAddLabel: (message: MailMessage, label: MailLabel) => void
|
||||||
onRemoveLabel: (message: MailMessage, labelId: string) => void
|
onRemoveLabel: (message: MailMessage, labelId: string) => void
|
||||||
|
canSend: boolean
|
||||||
|
canOrganize: boolean
|
||||||
|
canManageLabels: boolean
|
||||||
|
canDownloadAttachments: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||||
@@ -1182,14 +1260,14 @@ function CompactMessageDetail({
|
|||||||
<DropdownMenuItem onSelect={() => onSelect(selected.id)}><PencilLine className="h-4 w-4" />编辑草稿</DropdownMenuItem>
|
<DropdownMenuItem onSelect={() => onSelect(selected.id)}><PencilLine className="h-4 w-4" />编辑草稿</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem onSelect={() => onReply(selected)}><Reply className="h-4 w-4" />回复</DropdownMenuItem>
|
{canSend && <DropdownMenuItem onSelect={() => onReply(selected)}><Reply className="h-4 w-4" />回复</DropdownMenuItem>}
|
||||||
<DropdownMenuItem onSelect={() => onForward(selected)}><Forward className="h-4 w-4" />转发</DropdownMenuItem>
|
{canSend && <DropdownMenuItem onSelect={() => onForward(selected)}><Forward className="h-4 w-4" />转发</DropdownMenuItem>}
|
||||||
<DropdownMenuItem onSelect={() => onArchive(selected)}><Archive className="h-4 w-4" />{selected.folder === "Archive" ? "取消归档" : "归档"}</DropdownMenuItem>
|
{canOrganize && <DropdownMenuItem onSelect={() => onArchive(selected)}><Archive className="h-4 w-4" />{selected.folder === "Archive" ? "取消归档" : "归档"}</DropdownMenuItem>}
|
||||||
<DropdownMenuItem onSelect={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</DropdownMenuItem>
|
{canOrganize && <DropdownMenuItem onSelect={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</DropdownMenuItem>}
|
||||||
<DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>
|
{canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" />删除</DropdownMenuItem>
|
{canOrganize && <DropdownMenuItem onSelect={() => onDelete(selected)} className="text-destructive"><Trash2 className="h-4 w-4" />删除</DropdownMenuItem>}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
@@ -1201,14 +1279,14 @@ function CompactMessageDetail({
|
|||||||
<Button variant="outline" size="sm" onClick={() => onSelect(selected.id)}><PencilLine className="h-4 w-4" />编辑草稿</Button>
|
<Button variant="outline" size="sm" onClick={() => onSelect(selected.id)}><PencilLine className="h-4 w-4" />编辑草稿</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{selected && <Button variant="outline" size="sm" onClick={() => onReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
{selected && canSend && <Button variant="outline" size="sm" onClick={() => onReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
||||||
{selected && <Button variant="outline" size="sm" onClick={() => onForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
{selected && canSend && <Button variant="outline" size="sm" onClick={() => onForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
||||||
{selected && <Button variant="outline" size="sm" onClick={() => onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}</Button>}
|
||||||
{selected && <Button variant="outline" size="sm" onClick={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</Button>}
|
||||||
{selected && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{selected && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" />删除</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onDelete(selected)}><Trash2 className="h-4 w-4" />删除</Button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}>上一封</Button>
|
<Button variant="ghost" size="sm" disabled={!previousMessage} onClick={() => previousMessage && onSelect(previousMessage.id)}>上一封</Button>
|
||||||
@@ -1224,11 +1302,12 @@ function CompactMessageDetail({
|
|||||||
<div className="space-y-5 border-b pb-5">
|
<div className="space-y-5 border-b pb-5">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{selected.subject}</h1>
|
<h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{selected.subject}</h1>
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
|
{canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
|
||||||
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
|
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||||
</Button>
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
<MessageMetaPanel message={selected} />
|
<MessageMetaPanel message={selected} />
|
||||||
|
{canManageLabels && (
|
||||||
<MessageLabels
|
<MessageLabels
|
||||||
messageLabels={selected.labels || []}
|
messageLabels={selected.labels || []}
|
||||||
availableLabels={labels}
|
availableLabels={labels}
|
||||||
@@ -1236,10 +1315,11 @@ function CompactMessageDetail({
|
|||||||
onRemove={(labelId) => onRemoveLabel(selected, labelId)}
|
onRemove={(labelId) => onRemoveLabel(selected, labelId)}
|
||||||
pending={labelPending}
|
pending={labelPending}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="py-6 sm:py-8">
|
<div className="py-6 sm:py-8">
|
||||||
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
|
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${escapeHtml(selected.bodyText || "")}</pre>`) }} />
|
||||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex flex-col gap-1 rounded-md border p-3 text-sm hover:bg-accent sm:flex-row sm:items-center sm:justify-between" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>}
|
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex flex-col gap-1 rounded-md border p-3 text-sm hover:bg-accent sm:flex-row sm:items-center sm:justify-between" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex flex-col gap-1 rounded-md border p-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between" key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -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 visibleLabels = (message.labels || []).slice(0, 2)
|
||||||
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
|
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
|
||||||
const senderName = senderDisplayName(message)
|
const senderName = senderDisplayName(message)
|
||||||
@@ -1266,9 +1346,9 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
|
|||||||
<div className="min-w-0 truncate" title={senderTitle(message)}>{senderName}</div>
|
<div className="min-w-0 truncate" title={senderTitle(message)}>{senderName}</div>
|
||||||
<div className="flex shrink-0 items-center gap-1 sm:hidden">
|
<div className="flex shrink-0 items-center gap-1 sm:hidden">
|
||||||
<span className="text-xs text-muted-foreground">{formatDate(message.receivedAt)}</span>
|
<span className="text-xs text-muted-foreground">{formatDate(message.receivedAt)}</span>
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="h-7 w-7 text-muted-foreground hover:text-yellow-500" onClick={(event) => { event.stopPropagation(); onStar() }}>
|
{canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="h-7 w-7 text-muted-foreground hover:text-yellow-500" onClick={(event) => { event.stopPropagation(); onStar() }}>
|
||||||
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
|
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||||
</Button>
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0">
|
<div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0">
|
||||||
@@ -1283,9 +1363,9 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden shrink-0 text-right text-xs text-muted-foreground sm:block">{formatDate(message.receivedAt)}</div>
|
<div className="hidden shrink-0 text-right text-xs text-muted-foreground sm:block">{formatDate(message.receivedAt)}</div>
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="hidden h-7 w-7 text-muted-foreground hover:text-yellow-500 sm:inline-flex" onClick={(event) => { event.stopPropagation(); onStar() }}>
|
{canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={message.isStarred ? "取消星标" : "添加星标"} className="hidden h-7 w-7 text-muted-foreground hover:text-yellow-500 sm:inline-flex" onClick={(event) => { event.stopPropagation(); onStar() }}>
|
||||||
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
|
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||||
</Button>
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1525,6 +1605,7 @@ function MessageRow({
|
|||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
onClick,
|
onClick,
|
||||||
onStar,
|
onStar,
|
||||||
|
canOrganize,
|
||||||
}: {
|
}: {
|
||||||
message: MailMessage
|
message: MailMessage
|
||||||
active: boolean
|
active: boolean
|
||||||
@@ -1533,6 +1614,7 @@ function MessageRow({
|
|||||||
onCheckedChange: (checked: boolean) => void
|
onCheckedChange: (checked: boolean) => void
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
onStar: () => void
|
onStar: () => void
|
||||||
|
canOrganize: boolean
|
||||||
}) {
|
}) {
|
||||||
const visibleLabels = (message.labels || []).slice(0, 2)
|
const visibleLabels = (message.labels || []).slice(0, 2)
|
||||||
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
|
const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0)
|
||||||
@@ -1550,7 +1632,7 @@ function MessageRow({
|
|||||||
<div className="mb-1 flex items-center justify-between gap-2">
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0 truncate text-sm" title={senderTitle(message)}>{senderName}</div>
|
<div className="min-w-0 truncate text-sm" title={senderTitle(message)}>{senderName}</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button
|
{canOrganize && <Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -1559,7 +1641,7 @@ function MessageRow({
|
|||||||
onClick={(e) => { e.stopPropagation(); onStar() }}
|
onClick={(e) => { e.stopPropagation(); onStar() }}
|
||||||
>
|
>
|
||||||
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
|
<Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||||
</Button>
|
</Button>}
|
||||||
<div className="text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div>
|
<div className="text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [files, setFiles] = React.useState<File[]>([])
|
const [files, setFiles] = React.useState<File[]>([])
|
||||||
@@ -1651,7 +1733,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
|
|||||||
const [showCc, setShowCc] = React.useState(Boolean(draft?.cc))
|
const [showCc, setShowCc] = React.useState(Boolean(draft?.cc))
|
||||||
const [showBcc, setShowBcc] = React.useState(Boolean(draft?.bcc))
|
const [showBcc, setShowBcc] = React.useState(Boolean(draft?.bcc))
|
||||||
const [sendSeparately, setSendSeparately] = React.useState(false)
|
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 signatureText = defaultSignature.data?.signature?.content || ""
|
||||||
const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "")
|
const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "")
|
||||||
const [body, setBody] = React.useState<ComposerValue>(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText))
|
const [body, setBody] = React.useState<ComposerValue>(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText))
|
||||||
@@ -1745,7 +1827,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
|
|||||||
}, [files])
|
}, [files])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open || sendStartedRef.current || !hasDraftContent) return
|
if (!open || sendStartedRef.current || !hasDraftContent || !canManageDrafts) return
|
||||||
const payloadKey = JSON.stringify({ ...composePayload, draftId })
|
const payloadKey = JSON.stringify({ ...composePayload, draftId })
|
||||||
if (payloadKey === lastSavedPayloadRef.current) return
|
if (payloadKey === lastSavedPayloadRef.current) return
|
||||||
const timer = window.setTimeout(async () => {
|
const timer = window.setTimeout(async () => {
|
||||||
@@ -1766,7 +1848,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
|
|||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [open, hasDraftContent, composePayload, draftId, qc])
|
}, [open, hasDraftContent, composePayload, draftId, qc, canManageDrafts])
|
||||||
|
|
||||||
function buildSendWarnings(attachmentsCount: number) {
|
function buildSendWarnings(attachmentsCount: number) {
|
||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
@@ -1791,6 +1873,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function prepareSend() {
|
async function prepareSend() {
|
||||||
|
if (!canSend) return
|
||||||
if (!mailbox) return
|
if (!mailbox) return
|
||||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||||
const to = splitEmails(toValue)
|
const to = splitEmails(toValue)
|
||||||
@@ -1824,6 +1907,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
|
|||||||
await prepareSend()
|
await prepareSend()
|
||||||
}
|
}
|
||||||
async function scheduleAt(sendAt: string) {
|
async function scheduleAt(sendAt: string) {
|
||||||
|
if (!canSchedule) return
|
||||||
if (!mailbox) {
|
if (!mailbox) {
|
||||||
toast({ title: "请选择发件邮箱" })
|
toast({ title: "请选择发件邮箱" })
|
||||||
return
|
return
|
||||||
@@ -1913,8 +1997,8 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter className="grid grid-cols-3 gap-2 border-t bg-background px-4 py-3 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4">
|
<DialogFooter className="grid grid-cols-3 gap-2 border-t bg-background px-4 py-3 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4">
|
||||||
<Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}>取消</Button>
|
<Button type="button" variant="outline" className="min-h-10 px-3" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
<Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !mailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" />定时</Button>
|
{canSchedule && <Button type="button" variant="outline" className="min-h-10 px-3" disabled={send.isPending || scheduleSend.isPending || !mailbox} onClick={() => setScheduleDialogOpen(true)}><Calendar className="h-4 w-4" />定时</Button>}
|
||||||
<Button className="min-h-10 px-4" disabled={send.isPending || !mailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>
|
{canSend && <Button className="min-h-10 px-4" disabled={send.isPending || !mailbox}><Send className="h-4 w-4" />{send.isPending ? "发送中..." : "发送"}</Button>}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
|
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useMe } from "@/hooks/use-me"
|
|||||||
import { useLogout } from "@/hooks/use-logout"
|
import { useLogout } from "@/hooks/use-logout"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { validatePasswordConfirm } from "@/lib/validation"
|
import { validatePasswordConfirm } from "@/lib/validation"
|
||||||
|
import { hasPermission } from "@/lib/permissions"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { PasswordInput } from "@/components/ui/password-input"
|
import { PasswordInput } from "@/components/ui/password-input"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -67,19 +68,41 @@ export function ProfilePage() {
|
|||||||
const themeMountedRef = React.useRef(false)
|
const themeMountedRef = React.useRef(false)
|
||||||
|
|
||||||
const rawTab = params.get("tab") as Tab | null
|
const rawTab = params.get("tab") as Tab | null
|
||||||
const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile"
|
|
||||||
const user = me.data?.user
|
const user = me.data?.user
|
||||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
|
const canAccessMail = hasPermission(user, "mail.access")
|
||||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
|
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 publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
|
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts })
|
||||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures })
|
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures })
|
||||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
|
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules })
|
||||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
|
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 selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||||
const activeMailboxId = selectedMailbox?.id || ""
|
const activeMailboxId = selectedMailbox?.id || ""
|
||||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(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 })
|
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
|
||||||
|
|
||||||
const profile = useMutation({
|
const profile = useMutation({
|
||||||
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
|
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
|
||||||
@@ -191,7 +214,11 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
const logout = useLogout()
|
const logout = useLogout()
|
||||||
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
|
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)) }
|
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
|
||||||
if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">加载中...</div>
|
if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">加载中...</div>
|
||||||
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
||||||
@@ -205,7 +232,7 @@ export function ProfilePage() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
{!sidebarCollapsed && <SidebarGroupLabel>个人中心</SidebarGroupLabel>}
|
{!sidebarCollapsed && <SidebarGroupLabel>个人中心</SidebarGroupLabel>}
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>{tabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
|
<SidebarMenu>{visibleTabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
@@ -264,19 +291,19 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
function renderTab() {
|
function renderTab() {
|
||||||
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
|
if (tab === "mailboxes") return <MailboxManagement mailboxes={canAccessMail ? mailboxes.data?.items || [] : []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
|
||||||
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
|
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
|
||||||
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
|
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
|
||||||
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
|
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
|
||||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => 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 <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
|
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
|
||||||
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
|
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<HTMLFormElement>; stats?: MailStats; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; 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<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -424,7 +451,7 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<StatsSummary stats={stats} />
|
{showStats && <StatsSummary stats={stats} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
function confirmCleanup(target: "empty-trash" | "empty-spam" | "archive-read-inbox", title: string, destructive = false) {
|
function confirmCleanup(target: "empty-trash" | "empty-spam" | "archive-read-inbox", title: string, destructive = false) {
|
||||||
setPendingConfirm({
|
setPendingConfirm({
|
||||||
@@ -731,7 +758,7 @@ function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mail
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StatsSummary stats={stats} />
|
{showStats && <StatsSummary stats={stats} />}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>清理当前邮箱</CardTitle></CardHeader>
|
<CardHeader><CardTitle>清理当前邮箱</CardTitle></CardHeader>
|
||||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user