Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6439b3c2dd | |||
| b982c30cd0 |
@@ -1,8 +0,0 @@
|
||||
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
|
||||
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
|
||||
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
|
||||
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
|
||||
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
|
||||
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
|
||||
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
|
||||
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
|
||||
@@ -1,5 +0,0 @@
|
||||
- 修复 QQ 邮箱、网易邮箱、Gmail 等第三方客户端可以收信但无法发信的问题。
|
||||
- SMTP 提交服务新增 `AUTH LOGIN` 认证支持,并继续兼容 `AUTH PLAIN`;两种方式均只允许在 TLS 加密连接中使用。
|
||||
- 兼容带初始用户名和标准两步用户名/密码挑战的 LOGIN 流程,适配常见手机邮箱、Apple Mail 和 Thunderbird。
|
||||
- 客户端配置保持 IMAP 993/SSL、POP3 995/SSL、SMTP 465/SSL,不增加额外服务器地址或备用配置。
|
||||
- 新增 SMTP 能力声明、LOGIN 认证、STARTTLS、隐式 TLS 和完整发信回归测试。
|
||||
@@ -1,5 +0,0 @@
|
||||
- 优化转发验证完成页:移除“返回邮箱”入口,外部收件人确认 Netflix、ChatGPT 等验证码转发授权后不会进入邮箱登录页,只显示验证结果和关闭页面提示。
|
||||
- 合并验证邮箱搜索与添加入口:输入内容会实时筛选已添加地址,输入新邮箱时可直接发送验证邮件,已存在地址会明确显示为“已添加”。
|
||||
- 重整验证邮箱管理列表:待验证邮箱置顶展示,已验证邮箱按数字和字母排序并聚合为可折叠分组,邮箱数量较多时仍便于查找和管理。
|
||||
- 精简邮件转发主页面:不再平铺全部验证邮箱标签,改为显示已验证与待验证数量汇总,点击即可进入管理列表。
|
||||
- 补充验证完成页回归测试,确保页面不再出现邮箱首页或登录入口,并完成前端构建、组件规范、后端全量测试与静态检查。
|
||||
@@ -1,3 +0,0 @@
|
||||
- 精简邮件转发页面入口,移除右上角重复的“管理验证邮箱”按钮。
|
||||
- 保留下方汇总入口并统一命名为“管理验证邮箱”,继续显示已验证与待验证邮箱数量。
|
||||
- 没有验证邮箱时仍显示该入口,用户可直接进入添加和验证邮箱。
|
||||
@@ -1,9 +0,0 @@
|
||||
- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。
|
||||
- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。
|
||||
- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。
|
||||
- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。
|
||||
- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。
|
||||
- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。
|
||||
- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。
|
||||
- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。
|
||||
- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。
|
||||
@@ -1,7 +0,0 @@
|
||||
- 修复手机端删除自定义文件夹时菜单先消失、确认框无法显示的问题,文件夹菜单现在与移动侧栏保持在同一交互层中。
|
||||
- 删除文件夹前会稳定显示确认信息,提交后显示处理中状态,避免重复操作;文件夹内邮件仍会安全移回收件箱。
|
||||
- 修复手机侧栏中新建文件夹弹窗被侧栏遮挡或立即关闭的问题,侧栏退出后再打开创建界面。
|
||||
- 重做手机端“新建规则 / 编辑规则”布局:标题、表单与底部操作区改为纵向结构,内容区域可独立滚动。
|
||||
- 优化规则条件与动作的窄屏排列,字段和运算符并排、输入框独占一行,添加与删除按钮保持易点击且不会挤出屏幕。
|
||||
- 规则底部创建和取消按钮固定可见并适配手机安全区域,多条件、多动作时仍可顺畅滚动和提交。
|
||||
- 优化通用确认弹窗的手机宽度和按钮触控尺寸,减少误触并避免贴边显示。
|
||||
@@ -231,8 +231,6 @@ pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
|
||||
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -317,7 +317,6 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT 'folder',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -691,9 +690,6 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateFolderIcons(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateExternalIMAP(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1050,191 +1050,6 @@ func TestMailRulesForwardingAction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailRulesExactSenderCustomFolderAndStopProcessing(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", code)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
sender := createTestMailbox(t, admin, domainID, "rule-exact-sender", "Sender With Name", "Password123!", nil)
|
||||
recipient := createTestMailbox(t, admin, domainID, "rule-custom-target", "Rule Target", "Password123!", nil)
|
||||
|
||||
rcpt := &testClient{t: t, server: ts}
|
||||
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("recipient login=%d", code)
|
||||
}
|
||||
var bad map[string]any
|
||||
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||
"mailboxId": recipient.ID,
|
||||
"conditions": []map[string]string{{"field": "size", "operator": "contains", "value": "10"}},
|
||||
"actions": []map[string]string{{"type": "archive"}},
|
||||
}, &bad); code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid field operator should be rejected code=%d body=%v", code, bad)
|
||||
}
|
||||
|
||||
createRule := func(name string, action map[string]string, stop bool) {
|
||||
t.Helper()
|
||||
var rule MailRule
|
||||
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||
"mailboxId": recipient.ID,
|
||||
"name": name,
|
||||
"conditions": []map[string]string{{"field": "from", "operator": "equals", "value": sender.Address}},
|
||||
"actions": []map[string]string{action},
|
||||
"stopProcessing": stop,
|
||||
}, &rule); code != http.StatusCreated {
|
||||
t.Fatalf("create rule %s code=%d rule=%+v", name, code, rule)
|
||||
}
|
||||
}
|
||||
createRule("fallback archive", map[string]string{"type": "archive"}, false)
|
||||
createRule("Netflix folder", map[string]string{"type": "move", "value": "Netflix 验证码"}, true)
|
||||
|
||||
senderClient := &testClient{t: t, server: ts}
|
||||
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("sender login=%d", code)
|
||||
}
|
||||
var sent MailMessage
|
||||
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": "Netflix code", "text": "123456"}, &sent); code != http.StatusCreated {
|
||||
t.Fatalf("send code=%d sent=%+v", code, sent)
|
||||
}
|
||||
var custom struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder="+url.QueryEscape("Netflix 验证码"), nil, &custom); code != http.StatusOK || len(custom.Items) != 1 {
|
||||
t.Fatalf("custom rule folder code=%d items=%+v", code, custom.Items)
|
||||
}
|
||||
var archived struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 0 {
|
||||
t.Fatalf("stop processing should prevent fallback archive code=%d items=%+v", code, archived.Items)
|
||||
}
|
||||
var icon string
|
||||
if err := a.db.QueryRow(`SELECT icon FROM folders WHERE mailbox_id=? AND name=?`, recipient.ID, "Netflix 验证码").Scan(&icon); err != nil || icon != "netflix" {
|
||||
t.Fatalf("rule-created folder icon=%q err=%v", icon, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderIconForName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested string
|
||||
want string
|
||||
}{
|
||||
{name: "Netflix 验证码", requested: "auto", want: "netflix"},
|
||||
{name: "ChatGPT 通知", want: "chatgpt"},
|
||||
{name: "OpenAI 账单", want: "chatgpt"},
|
||||
{name: "项目归档", want: "briefcase"},
|
||||
{name: "其他", want: "folder"},
|
||||
{name: "Netflix", requested: "heart", want: "heart"},
|
||||
{name: "Netflix", requested: "unknown", want: "folder"},
|
||||
{name: "Custom", requested: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", want: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="},
|
||||
{name: "Egypt archive", want: "folder"},
|
||||
{name: "Custom", requested: "data:image/svg+xml;base64,PHN2Zz4=", want: "folder"},
|
||||
{name: "Custom", requested: "data:image/png;base64,SGVsbG8=", want: "folder"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+"/"+tt.requested, func(t *testing.T) {
|
||||
if got := folderIconForName(tt.name, tt.requested); got != tt.want {
|
||||
t.Fatalf("folderIconForName(%q, %q)=%q want %q", tt.name, tt.requested, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleFolderAutoIconPreservesManualSelection(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
admin := &testClient{t: t, server: httptest.NewServer(a.Router())}
|
||||
defer admin.server.Close()
|
||||
|
||||
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", code)
|
||||
}
|
||||
domainID := createTestDomain(t, admin, "manual-icon.test")
|
||||
mailbox := createTestMailbox(t, admin, domainID.ID, "rules", "Rules", "Password123!", nil)
|
||||
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "heart"); err != nil {
|
||||
t.Fatalf("create custom folder: %v", err)
|
||||
}
|
||||
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "auto"); err != nil {
|
||||
t.Fatalf("reuse custom folder: %v", err)
|
||||
}
|
||||
var icon string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT icon FROM folders WHERE mailbox_id=? AND name='Netflix'`, mailbox.ID).Scan(&icon); err != nil || icon != "heart" {
|
||||
t.Fatalf("manual icon should be preserved icon=%q err=%v", icon, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailRuleApplyExistingWhenDisabledExcludesSent(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", code)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
sender := createTestMailbox(t, admin, domainID, "rule-existing-sender", "Existing Sender", "Password123!", nil)
|
||||
recipient := createTestMailbox(t, admin, domainID, "rule-existing-recipient", "Existing Recipient", "Password123!", nil)
|
||||
subject := "same inbound and sent subject"
|
||||
|
||||
senderClient := &testClient{t: t, server: ts}
|
||||
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("sender login=%d", code)
|
||||
}
|
||||
var incomingSend MailMessage
|
||||
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": subject, "text": "incoming"}, &incomingSend); code != http.StatusCreated {
|
||||
t.Fatalf("incoming send code=%d", code)
|
||||
}
|
||||
|
||||
rcpt := &testClient{t: t, server: ts}
|
||||
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("recipient login=%d", code)
|
||||
}
|
||||
var outgoing MailMessage
|
||||
if code := rcpt.do("POST", "/api/mail/send", map[string]any{"to": []string{sender.Address}, "subject": subject, "text": "outgoing"}, &outgoing); code != http.StatusCreated {
|
||||
t.Fatalf("outgoing send code=%d", code)
|
||||
}
|
||||
var rule MailRule
|
||||
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||
"mailboxId": recipient.ID,
|
||||
"name": "existing disabled",
|
||||
"conditions": []map[string]string{{"field": "subject", "operator": "equals", "value": subject}},
|
||||
"actions": []map[string]string{{"type": "star"}},
|
||||
"applyToExisting": true,
|
||||
"enabled": false,
|
||||
}, &rule); code != http.StatusCreated || rule.AppliedExistingCount != 1 || rule.Enabled {
|
||||
t.Fatalf("create disabled existing rule code=%d rule=%+v", code, rule)
|
||||
}
|
||||
var inboundStarred, sentStarred int
|
||||
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE mailbox_id=? AND subject=? AND folder_id IN (SELECT id FROM folders WHERE mailbox_id=? AND lower(name)='inbox')`, recipient.ID, subject, recipient.ID).Scan(&inboundStarred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE id=?`, outgoing.ID).Scan(&sentStarred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inboundStarred != 1 || sentStarred != 0 {
|
||||
t.Fatalf("existing rule starred inbound=%d sent=%d", inboundStarred, sentStarred)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleAttachmentConditionUsesFilenameOnly(t *testing.T) {
|
||||
msg := ruleMessage{AttachmentNames: "notes.txt"}
|
||||
if ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "contains", Value: "pdf"}, msg) {
|
||||
t.Fatal("attachment condition must not match MIME type or unrelated extension")
|
||||
}
|
||||
if !ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "ends-with", Value: ".txt"}, msg) {
|
||||
t.Fatal("attachment condition should match filename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailRulesMailboxIsolation(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -2478,7 +2293,7 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
||||
}
|
||||
|
||||
var custom MailFolder
|
||||
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档", "icon": "netflix"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" || custom.Icon != "netflix" {
|
||||
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" {
|
||||
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
|
||||
}
|
||||
var folders struct {
|
||||
@@ -2487,15 +2302,6 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
||||
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
|
||||
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
|
||||
}
|
||||
foundIcon := ""
|
||||
for _, folder := range folders.Items {
|
||||
if folder.Name == "客户归档" {
|
||||
foundIcon = folder.Icon
|
||||
}
|
||||
}
|
||||
if foundIcon != "netflix" {
|
||||
t.Fatalf("folder icon=%q, want netflix", foundIcon)
|
||||
}
|
||||
|
||||
var sent MailMessage
|
||||
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
|
||||
@@ -2807,25 +2613,6 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardingVerificationPageDoesNotLinkToMailbox(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
a.renderForwardingVerificationPage(recorder, http.StatusOK, true, "friend@example.test", "该邮箱已通过转发验证")
|
||||
body := recorder.Body.String()
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", recorder.Code)
|
||||
}
|
||||
for _, forbidden := range []string{`href="/"`, "返回邮箱", "登录"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("verification page contains forbidden navigation %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "可以关闭此页面") {
|
||||
t.Fatalf("verification page is missing close guidance: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
@@ -4705,49 +4492,6 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionLoginAuthenticationWithAndWithoutInitialResponse(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
for _, withInitialResponse := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("initial-response-%t", withInitialResponse), func(t *testing.T) {
|
||||
session := &submissionSession{app: a}
|
||||
if mechanisms := strings.Join(session.AuthMechanisms(), " "); mechanisms != "PLAIN LOGIN" {
|
||||
t.Fatalf("submission auth mechanisms=%q", mechanisms)
|
||||
}
|
||||
server, err := session.Auth(sasl.Login)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response []byte
|
||||
if withInitialResponse {
|
||||
response = []byte("admin@lanqin.local")
|
||||
}
|
||||
challenge, done, err := server.Next(response)
|
||||
if err != nil || done {
|
||||
t.Fatalf("initial LOGIN response err=%v done=%t", err, done)
|
||||
}
|
||||
if !withInitialResponse {
|
||||
if string(challenge) != "Username:" {
|
||||
t.Fatalf("username challenge=%q", challenge)
|
||||
}
|
||||
challenge, done, err = server.Next([]byte("admin@lanqin.local"))
|
||||
if err != nil || done {
|
||||
t.Fatalf("username response err=%v done=%t", err, done)
|
||||
}
|
||||
}
|
||||
if string(challenge) != "Password:" {
|
||||
t.Fatalf("password challenge=%q", challenge)
|
||||
}
|
||||
challenge, done, err = server.Next([]byte("ChangeMe123!"))
|
||||
if err != nil || !done || challenge != nil {
|
||||
t.Fatalf("password response challenge=%q err=%v done=%t", challenge, err, done)
|
||||
}
|
||||
if session.user == nil || session.mailbox == nil {
|
||||
t.Fatal("LOGIN authentication did not populate submission session")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
host, port, received := startCapturingSMTP(t, 2)
|
||||
@@ -4785,7 +4529,7 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.Auth(sasl.NewLoginClient("admin@lanqin.local", "ChangeMe123!")); err != nil {
|
||||
if err := client.Auth(sasl.NewPlainClient("", "admin@lanqin.local", "ChangeMe123!")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
|
||||
|
||||
@@ -165,7 +165,7 @@ func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request
|
||||
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已通过转发验证")
|
||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标")
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -439,18 +439,14 @@ func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int
|
||||
title := "邮箱转发验证"
|
||||
heading := "验证失败"
|
||||
color := "#dc2626"
|
||||
statusMark := "!"
|
||||
closingMessage := "请联系验证发起人重新发送链接"
|
||||
if ok {
|
||||
heading = "验证完成"
|
||||
color = "#16a34a"
|
||||
statusMark = "✓"
|
||||
closingMessage = "验证结果已记录,可以关闭此页面"
|
||||
color = "#2563eb"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:8px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><div aria-hidden="true" style="display:grid;place-items:center;width:44px;height:44px;margin:0 0 20px;border-radius:50%%;background:%s;color:white;font-size:24px;font-weight:700">%s</div><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 24px;font-size:15px;color:#64748b;word-break:break-all">%s</p><p style="margin:0;padding-top:20px;border-top:1px solid #e2e8f0;font-size:15px;color:#64748b">%s</p></section></main></body></html>`,
|
||||
title, color, statusMark, heading, htmlEscape(message), htmlEscape(email), htmlEscape(closingMessage))
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:14px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 26px;font-size:15px;color:#64748b">%s</p><a href="/" style="display:inline-block;border-radius:8px;background:%s;color:white;text-decoration:none;padding:12px 18px;font-weight:700">返回邮箱</a></section></main></body></html>`,
|
||||
title, heading, htmlEscape(message), htmlEscape(email), color)
|
||||
}
|
||||
|
||||
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
|
||||
|
||||
@@ -76,10 +76,6 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateFolderIcons(ctx context.Context) error {
|
||||
return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`)
|
||||
}
|
||||
|
||||
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
@@ -100,12 +98,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
ORDER BY CASE
|
||||
WHEN lower(f.name)='inbox' THEN 1000
|
||||
WHEN lower(f.name)='sent' THEN 5000
|
||||
@@ -123,7 +121,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
@@ -134,7 +132,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
||||
@@ -160,7 +158,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
@@ -268,7 +266,6 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -283,7 +280,6 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("system folder already exists"))
|
||||
return
|
||||
}
|
||||
icon := folderIconForName(name, req.Icon)
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
|
||||
@@ -312,12 +308,12 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
for _, mailboxID := range mailboxIDs {
|
||||
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name, icon); err != nil {
|
||||
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||
return
|
||||
}
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), Icon: icon, SortOrder: customFolderDefaultSortOrderBase})
|
||||
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase})
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
@@ -325,7 +321,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
|
||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||
return
|
||||
@@ -531,88 +527,8 @@ func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request,
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
|
||||
}
|
||||
|
||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
|
||||
var existingID string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
|
||||
if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
|
||||
return existingID, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
id, err := a.ensureFolder(ctx, mailboxID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func folderIconForName(name, requested string) string {
|
||||
if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
|
||||
return normalizeFolderIcon(icon)
|
||||
}
|
||||
value := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, match := range []struct {
|
||||
icon string
|
||||
terms []string
|
||||
}{
|
||||
{"netflix", []string{"netflix", "奈飞", "网飞"}},
|
||||
{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
|
||||
{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
|
||||
{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
|
||||
{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
|
||||
{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
|
||||
{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
|
||||
{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
|
||||
{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
|
||||
{"star", []string{"重要", "紧急", "important", "urgent"}},
|
||||
{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
|
||||
{"bell", []string{"提醒", "通知", "remind", "notification"}},
|
||||
{"mail", []string{"邮件", "邮箱", "mail", "email"}},
|
||||
} {
|
||||
for _, term := range match.terms {
|
||||
if folderNameContainsTerm(value, term) {
|
||||
return match.icon
|
||||
}
|
||||
}
|
||||
}
|
||||
return "folder"
|
||||
}
|
||||
|
||||
func folderNameContainsTerm(value, term string) bool {
|
||||
if term != "gpt" {
|
||||
return strings.Contains(value, term)
|
||||
}
|
||||
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
|
||||
}) {
|
||||
if token == term {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeFolderIcon(raw string) string {
|
||||
icon := strings.TrimSpace(raw)
|
||||
const customPrefix = "data:image/png;base64,"
|
||||
if strings.HasPrefix(icon, customPrefix) {
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
|
||||
config, configErr := png.DecodeConfig(bytes.NewReader(data))
|
||||
validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
|
||||
if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
|
||||
return icon
|
||||
}
|
||||
return "folder"
|
||||
}
|
||||
icon = strings.ToLower(icon)
|
||||
switch icon {
|
||||
case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
|
||||
return icon
|
||||
default:
|
||||
return "folder"
|
||||
}
|
||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
|
||||
return a.ensureFolder(ctx, mailboxID, name)
|
||||
}
|
||||
|
||||
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
|
||||
@@ -2316,14 +2232,14 @@ func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
|
||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
||||
var f MailFolder
|
||||
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
|
||||
@@ -534,16 +534,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
appliedCount := int64(0)
|
||||
if req.ApplyToExisting {
|
||||
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||
if req.ApplyToExisting && enabled {
|
||||
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
||||
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
||||
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
|
||||
return
|
||||
}
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
||||
item, err := scanRule(row)
|
||||
@@ -1334,9 +1330,7 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||
if rule.StopProcessing {
|
||||
break
|
||||
}
|
||||
@@ -1381,7 +1375,7 @@ type ruleMessage struct {
|
||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||
var msg ruleMessage
|
||||
var toAddrs, ccAddrs, receivedAt string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||
if err != nil {
|
||||
return ruleMessage{}, false
|
||||
@@ -1413,7 +1407,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
|
||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
parts = append(parts, filename)
|
||||
parts = append(parts, filename, contentType)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -1459,23 +1453,15 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
if !validRuleConditionOperator(field, operator) {
|
||||
switch operator {
|
||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||
}
|
||||
|
||||
func validRuleConditionOperator(field, operator string) bool {
|
||||
switch field {
|
||||
case "size":
|
||||
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
|
||||
case "date":
|
||||
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
|
||||
default:
|
||||
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRuleMatchMode(matchMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||
case "any", "or":
|
||||
@@ -1724,38 +1710,24 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
||||
for _, action := range normalizeRuleActions(actions, "") {
|
||||
switch action.Type {
|
||||
case "archive":
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "trash":
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "move":
|
||||
target, err := normalizeFolderNameForUser(action.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderID string
|
||||
if isSystemFolderName(target) {
|
||||
folderID, err = a.ensureFolder(ctx, mailboxID, target)
|
||||
} else {
|
||||
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := ruleTargetFolder(action.Value)
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "star":
|
||||
starred := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil {
|
||||
@@ -1817,6 +1789,21 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
|
||||
return err
|
||||
}
|
||||
|
||||
func ruleTargetFolder(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "inbox":
|
||||
return "Inbox"
|
||||
case "archive":
|
||||
return "Archive"
|
||||
case "spam":
|
||||
return "Spam"
|
||||
case "trash":
|
||||
return "Trash"
|
||||
default:
|
||||
return "Archive"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
||||
args := []any{userID}
|
||||
where := `mb.user_id=?`
|
||||
@@ -1824,7 +1811,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
where += ` AND m.mailbox_id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -121,59 +121,21 @@ type submissionSession struct {
|
||||
}
|
||||
|
||||
func (s *submissionSession) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain, sasl.Login}
|
||||
return []string{sasl.Plain}
|
||||
}
|
||||
|
||||
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
||||
authenticate := func(username, password string) error {
|
||||
if !strings.EqualFold(mech, sasl.Plain) {
|
||||
return nil, smtpserver.ErrAuthUnknownMechanism
|
||||
}
|
||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
|
||||
if err != nil {
|
||||
return smtpserver.ErrAuthFailed
|
||||
}
|
||||
s.user, s.mailbox = user, mailbox
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(mech, sasl.Plain):
|
||||
return sasl.NewPlainServer(func(_, username, password string) error {
|
||||
return authenticate(username, password)
|
||||
}), nil
|
||||
case strings.EqualFold(mech, sasl.Login):
|
||||
return &submissionLoginServer{authenticate: authenticate}, nil
|
||||
default:
|
||||
return nil, smtpserver.ErrAuthUnknownMechanism
|
||||
}
|
||||
}
|
||||
|
||||
type submissionLoginServer struct {
|
||||
authenticate func(username, password string) error
|
||||
username string
|
||||
step int
|
||||
}
|
||||
|
||||
func (s *submissionLoginServer) Next(response []byte) ([]byte, bool, error) {
|
||||
switch s.step {
|
||||
case 0:
|
||||
if response == nil {
|
||||
s.step = 1
|
||||
return []byte("Username:"), false, nil
|
||||
}
|
||||
s.username = string(response)
|
||||
s.step = 2
|
||||
return []byte("Password:"), false, nil
|
||||
case 1:
|
||||
s.username = string(response)
|
||||
s.step = 2
|
||||
return []byte("Password:"), false, nil
|
||||
case 2:
|
||||
if err := s.authenticate(s.username, string(response)); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
s.step = 3
|
||||
return nil, true, nil
|
||||
default:
|
||||
return nil, false, sasl.ErrUnexpectedClientResponse
|
||||
}
|
||||
}
|
||||
|
||||
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
|
||||
|
||||
@@ -88,7 +88,6 @@ type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "2.1.1",
|
||||
"dompurify": "3.4.13",
|
||||
"dompurify": "3.4.12",
|
||||
"lucide-react": "^0.468.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "18.3.1",
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import React from "react"
|
||||
import { Navigate, useLocation } from "react-router-dom"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useMe, isTimeoutError } from "@/hooks/use-me"
|
||||
import { AuthLoading, AuthError } from "@/components/auth-states"
|
||||
import { isUnauthorizedError } from "@/lib/api"
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const me = useMe()
|
||||
const location = useLocation()
|
||||
|
||||
if (me.isLoading) return <AuthLoading />
|
||||
if (me.isError && !isUnauthorizedError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
||||
if (me.isError || !me.data?.user) {
|
||||
const from = `${location.pathname}${location.search}${location.hash}`
|
||||
return <Navigate to="/login" replace state={{ from }} />
|
||||
}
|
||||
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
||||
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function AuthLoading() {
|
||||
return <main className="grid min-h-screen place-items-center text-muted-foreground">加载中...</main>
|
||||
return <div className="grid min-h-screen place-items-center text-muted-foreground">加载中...</div>
|
||||
}
|
||||
|
||||
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<div className="w-full max-w-sm space-y-4 text-center">
|
||||
<div className="text-sm font-medium">服务暂时不可用</div>
|
||||
<div className="text-sm font-medium">无法连接后端服务</div>
|
||||
<div className="text-sm text-muted-foreground">{message}</div>
|
||||
<Button type="button" variant="outline" onClick={onRetry}>重新连接</Button>
|
||||
<Button type="button" variant="outline" onClick={onRetry}>重新加载</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ export function ConfirmDialog({
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
||||
<DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
|
||||
@@ -5,18 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
||||
import { api, isUnauthorizedError } from "@/lib/api"
|
||||
import { api } from "@/lib/api"
|
||||
import type { User } from "@/lib/api"
|
||||
|
||||
type MeResponse = { user: User }
|
||||
@@ -10,7 +10,11 @@ export function useMe(
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: api.me,
|
||||
retry: (failureCount, error) => !isUnauthorizedError(error) && failureCount < 1,
|
||||
retry: 1,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("请求超时")
|
||||
}
|
||||
|
||||
+19
-19
@@ -5,34 +5,34 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 9%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 0 0% 12%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 224 44% 12%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 12%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 42%;
|
||||
--accent: 0 0% 95%;
|
||||
--accent-foreground: 0 0% 12%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 358 88% 61%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 82%;
|
||||
--ring: 0 0% 32%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 216 22% 42%;
|
||||
--radius: 0.5rem;
|
||||
--app-sidebar-width: 18rem;
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 0 0% 16%;
|
||||
--sidebar-primary: 0 0% 12%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 224 44% 12%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 0 0% 95%;
|
||||
--sidebar-accent-foreground: 0 0% 12%;
|
||||
--sidebar-border: 0 0% 89.8%;
|
||||
--sidebar-ring: 0 0% 42%;
|
||||
--sidebar-accent: 210 40% 96.1%;
|
||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-border: 214.3 31.8% 91.4%;
|
||||
--sidebar-ring: 215.4 16.3% 46.9%;
|
||||
}
|
||||
|
||||
* { @apply border-border; }
|
||||
|
||||
@@ -57,7 +57,7 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||
export type MailMessage = {
|
||||
|
||||
+6
-24
@@ -4,17 +4,6 @@ export * from "./api-types"
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401
|
||||
}
|
||||
|
||||
export type MailSearchParams = {
|
||||
q?: string
|
||||
from?: string
|
||||
@@ -56,34 +45,27 @@ function appendMailSearchParams(params: URLSearchParams, search: MailSearchParam
|
||||
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||
const { timeoutMs, ...requestInit } = init
|
||||
const controller = new AbortController()
|
||||
let timedOut = false
|
||||
const timeout = window.setTimeout(() => {
|
||||
timedOut = true
|
||||
controller.abort()
|
||||
}, timeoutMs || REQUEST_TIMEOUT_MS)
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs || REQUEST_TIMEOUT_MS)
|
||||
const externalSignal = requestInit.signal
|
||||
const abortFromExternalSignal = () => controller.abort()
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) controller.abort()
|
||||
else externalSignal.addEventListener("abort", abortFromExternalSignal, { once: true })
|
||||
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true })
|
||||
}
|
||||
try {
|
||||
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`
|
||||
try { const body = await res.json(); message = body.error || message } catch {}
|
||||
throw new ApiError(message, res.status)
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new Error(timedOut ? "请求超时,请检查后端服务是否正常" : "请求已取消")
|
||||
throw new Error("请求超时,请检查后端服务是否正常")
|
||||
}
|
||||
if (error instanceof TypeError) throw new Error("无法连接后端服务,请检查服务状态")
|
||||
throw error instanceof Error ? error : new Error("网络请求失败")
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
externalSignal?.removeEventListener("abort", abortFromExternalSignal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,9 +215,9 @@ export const api = {
|
||||
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
|
||||
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
createFolder: (payload: { mailboxId?: string; name: string; icon?: string }) => {
|
||||
createFolder: (payload: { mailboxId?: string; name: string }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, icon: payload.icon }) })
|
||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
|
||||
},
|
||||
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export function safeReturnPath(value: unknown, fallback = "/"): string {
|
||||
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return fallback
|
||||
return value
|
||||
}
|
||||
@@ -101,14 +101,6 @@ export function AdminPage() {
|
||||
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
||||
const rawSection = params.get("section") as Section | null
|
||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||
const sectionQuery = section === "overview" ? overview
|
||||
: section === "users" ? users
|
||||
: section === "permissionGroups" ? permissionGroups
|
||||
: section === "domains" ? domains
|
||||
: section === "mailboxes" ? mailboxes
|
||||
: section === "aliases" ? aliases
|
||||
: section === "settings" ? settings
|
||||
: null
|
||||
|
||||
async function refreshAdminPage() {
|
||||
if (refreshing) return
|
||||
@@ -132,8 +124,6 @@ export function AdminPage() {
|
||||
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||
|
||||
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||
|
||||
{section === "overview" && canOverview && (
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
||||
@@ -407,7 +397,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配置</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -913,8 +903,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
||||
</Table>
|
||||
</div>
|
||||
{messages.isLoading && <Empty text="加载中..." />}
|
||||
{messages.isError && <QueryFailure error={messages.error} onRetry={() => { void messages.refetch() }} compact />}
|
||||
{!messages.isLoading && !messages.isError && items.length === 0 && <Empty text="暂无邮件" />}
|
||||
{!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />}
|
||||
{!messages.isLoading && messages.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
|
||||
@@ -1027,8 +1016,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
</Table>
|
||||
</div>
|
||||
{audit.isLoading && <Empty text="加载中..." />}
|
||||
{audit.isError && <QueryFailure error={audit.error} onRetry={() => { void audit.refetch() }} compact />}
|
||||
{!audit.isLoading && !audit.isError && items.length === 0 && <Empty text="暂无发送记录" />}
|
||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送记录" />}
|
||||
{!audit.isLoading && audit.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
||||
@@ -1342,7 +1330,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
||||
<div className="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="复制绑定码" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
|
||||
<Button type="button" variant="ghost" size="icon" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1441,8 +1429,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
||||
</CardContent>
|
||||
</Card>}
|
||||
|
||||
{settingsTab === "templates" && canViewTemplates && templates.isError && <QueryFailure error={templates.error} onRetry={() => { void templates.refetch() }} />}
|
||||
{settingsTab === "templates" && canViewTemplates && !templates.isError && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
||||
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
||||
|
||||
{settingsTab === "security" && <Card>
|
||||
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
||||
@@ -1821,18 +1808,6 @@ function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; va
|
||||
}
|
||||
function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg border p-4"><div className="text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div> }
|
||||
function Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
|
||||
|
||||
function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onRetry: () => void; compact?: boolean }) {
|
||||
return (
|
||||
<div className={cn("mb-4 flex flex-col gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 sm:flex-row sm:items-center sm:justify-between", compact && "mb-0")} role="alert">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-destructive">数据读取失败</div>
|
||||
<div className="mt-1 break-words text-sm text-muted-foreground">{queryErrorMessage(error)}</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0" onClick={onRetry}><RefreshCcw className="h-4 w-4" />重试</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className="flex items-center justify-between rounded-lg border p-3"><span className="font-medium">{domain.name}</span><Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "正常" : domain.dnsStatus}</Badge></div> }
|
||||
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
|
||||
|
||||
@@ -1993,7 +1968,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
|
||||
})
|
||||
}
|
||||
if (!canUpdate && !canResetPassword && !onDelete) return null
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理账号 ${accountPrimaryEmail(user)}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||
}
|
||||
|
||||
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
||||
@@ -2037,12 +2012,12 @@ function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
||||
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
if (!canUpdate && !onDelete) return null
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}>编辑邮箱</DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除邮箱</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
|
||||
}
|
||||
|
||||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
||||
if (!onToggle && !onDelete) return null
|
||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理转发 ${alias.source}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||
}
|
||||
|
||||
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
@@ -2171,15 +2146,15 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
||||
const user = me.data?.user
|
||||
const canCheckDNS = hasPermission(user, "admin.dns.check")
|
||||
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
|
||||
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) }, onError: (error) => toast({ title: "DNS 检测失败", description: error.message }) })
|
||||
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) } })
|
||||
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
||||
const content = <>
|
||||
<p className="mb-3 text-sm text-muted-foreground">以下为需要在域名 DNS 管理中添加的记录:</p>
|
||||
{records.isError ? <QueryFailure error={records.error} onRetry={() => { void records.refetch() }} compact /> : <div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>}
|
||||
<div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>
|
||||
{check.data && <>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"><CheckCircle2 className="h-4 w-4" />检测结果</div>
|
||||
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <DNSCheckRow key={k} name={k} check={v} />)}</div>
|
||||
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <div key={k} className="flex items-center gap-2 text-sm"><CheckCircle2 className={`h-4 w-4 shrink-0 ${v.ok ? "text-green-600" : "text-destructive"}`} /><span className="font-medium">{k.toUpperCase()}:</span> {v.message}</div>)}</div>
|
||||
</>}</>
|
||||
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button> : null
|
||||
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</div>
|
||||
@@ -2187,20 +2162,6 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
||||
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
||||
}
|
||||
|
||||
function DNSCheckRow({ name, check }: { name: string; check: { ok: boolean; message: string; found?: string[] } }) {
|
||||
const visibleRecords = check.found?.filter(Boolean) ?? []
|
||||
return <div className="space-y-1 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className={`mt-0.5 h-4 w-4 shrink-0 ${check.ok ? "text-green-600" : "text-destructive"}`} />
|
||||
<div className="min-w-0"><span className="font-medium">{name.toUpperCase()}:</span> {check.message}</div>
|
||||
</div>
|
||||
{!check.ok && visibleRecords.length > 0 && <div className="ml-6 rounded-md bg-muted/60 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-sans text-foreground">当前解析</div>
|
||||
<div className="space-y-1">{visibleRecords.map((record, index) => <div key={`${name}-${index}`} className="break-all">{record}</div>)}</div>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function dnsDescription(record: DNSRecord): string {
|
||||
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
|
||||
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
|
||||
@@ -2251,25 +2212,19 @@ function fieldNumber(form: FormData, name: string, fallback: number) {
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback
|
||||
}
|
||||
function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; className?: string }) {
|
||||
const id = React.useId()
|
||||
return (
|
||||
<div className={`flex min-h-14 items-center justify-between gap-4 ${className}`}>
|
||||
<Label htmlFor={id} className="text-base font-medium">{label}</Label>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||
<Label className="text-base font-medium">{label}</Label>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function Field({ label, required = true, id: suppliedId, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
||||
const generatedId = React.useId()
|
||||
const id = suppliedId || generatedId
|
||||
return <div className="space-y-2"><Label htmlFor={id}>{label}</Label><Input id={id} required={required} {...props} /></div>
|
||||
}
|
||||
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
|
||||
function MailboxLimitField({ defaultValue }: { defaultValue: number }) {
|
||||
const id = React.useId()
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>邮箱数量上限</Label>
|
||||
<Input id={id} name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
||||
<Label>邮箱数量上限</Label>
|
||||
<Input name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
||||
<div className="text-xs text-muted-foreground">普通用户默认 9 个,填 0 表示不限制。</div>
|
||||
</div>
|
||||
)
|
||||
@@ -2281,8 +2236,5 @@ function mailboxLimitFromForm(form: FormData, fallback = defaultMailboxLimitOver
|
||||
function effectiveMailboxLimit(user: AdminUser) {
|
||||
return user.mailboxLimitOverride ?? user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride
|
||||
}
|
||||
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) {
|
||||
const id = React.useId()
|
||||
return <div className="space-y-2"><Label htmlFor={id}>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger id={id}><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, itemLabel]) => <SelectItem key={value} value={value}>{itemLabel}</SelectItem>)}</SelectContent></Select></div>
|
||||
}
|
||||
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
||||
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { Link, Navigate, useLocation } from "react-router-dom"
|
||||
import { Link, Navigate } from "react-router-dom"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
@@ -10,12 +10,9 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { safeReturnPath } from "@/lib/navigation"
|
||||
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||
|
||||
export function LoginPage() {
|
||||
const me = useMe()
|
||||
const location = useLocation()
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
@@ -36,12 +33,9 @@ export function LoginPage() {
|
||||
onError: (e) => toast({ title: "登录失败", description: e.message }),
|
||||
})
|
||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||
const returnPath = safeReturnPath((location.state as { from?: unknown } | null)?.from)
|
||||
if (me.data?.user) return <Navigate to={returnPath} replace />
|
||||
if (publicSettings.isLoading) return <AuthLoading />
|
||||
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
|
||||
if (me.data?.user) return <Navigate to="/" replace />
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
@@ -88,6 +82,6 @@ export function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+57
-257
@@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bell, Bold, Bot, Briefcase, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, GraduationCap, Heart, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plane, Plus, Quote, Receipt, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, ShoppingBag, Signature, SlidersHorizontal, Smile, Sparkles, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, Users, X } from "lucide-react"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
||||
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
@@ -49,65 +49,6 @@ 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: <Ban className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
|
||||
function NetflixFolderIcon({ className }: { className?: string }) {
|
||||
return <span aria-hidden="true" className={cn("inline-flex items-center justify-center font-black text-red-600", className)}>N</span>
|
||||
}
|
||||
const customFolderIconOptions = [
|
||||
{ key: "folder", label: "文件夹", icon: Folder },
|
||||
{ key: "mail", label: "邮件", icon: Mail },
|
||||
{ key: "briefcase", label: "工作", icon: Briefcase },
|
||||
{ key: "users", label: "联系人", icon: Users },
|
||||
{ key: "receipt", label: "账单", icon: Receipt },
|
||||
{ key: "shopping", label: "购物", icon: ShoppingBag },
|
||||
{ key: "plane", label: "旅行", icon: Plane },
|
||||
{ key: "graduation", label: "学习", icon: GraduationCap },
|
||||
{ key: "heart", label: "收藏", icon: Heart },
|
||||
{ key: "star", label: "重要", icon: Star },
|
||||
{ key: "bell", label: "提醒", icon: Bell },
|
||||
{ key: "shield", label: "安全", icon: ShieldCheck },
|
||||
{ key: "tag", label: "分类", icon: Tag },
|
||||
{ key: "netflix", label: "Netflix", icon: NetflixFolderIcon },
|
||||
{ key: "chatgpt", label: "ChatGPT", icon: Bot },
|
||||
] as const
|
||||
function suggestedFolderIcon(name: string) {
|
||||
const value = name.trim().toLocaleLowerCase()
|
||||
if (/netflix|奈飞|网飞/.test(value)) return "netflix"
|
||||
if (/chatgpt|openai|\bgpt\b/.test(value)) return "chatgpt"
|
||||
if (/账单|发票|收据|bill|invoice|receipt/.test(value)) return "receipt"
|
||||
if (/购物|订单|快递|shop|order|delivery/.test(value)) return "shopping"
|
||||
if (/旅行|旅游|机票|酒店|travel|trip|flight|hotel/.test(value)) return "plane"
|
||||
if (/学习|教育|课程|学校|study|school|course/.test(value)) return "graduation"
|
||||
if (/联系人|团队|用户|contact|team|people/.test(value)) return "users"
|
||||
if (/工作|项目|客户|work|project|business|client/.test(value)) return "briefcase"
|
||||
if (/收藏|喜欢|favorite|favourite/.test(value)) return "heart"
|
||||
if (/重要|紧急|important|urgent/.test(value)) return "star"
|
||||
if (/安全|验证|密码|登录|security|verify|password|login/.test(value)) return "shield"
|
||||
if (/提醒|通知|remind|notification/.test(value)) return "bell"
|
||||
if (/邮件|邮箱|mail|email/.test(value)) return "mail"
|
||||
return "folder"
|
||||
}
|
||||
|
||||
async function prepareFolderIcon(file: File) {
|
||||
if (!/^image\/(png|jpe?g|webp)$/i.test(file.type)) throw new Error("仅支持 PNG、JPG 或 WebP 图片")
|
||||
if (file.size > 2 * 1024 * 1024) throw new Error("原图不能超过 2 MB")
|
||||
const bitmap = await createImageBitmap(file)
|
||||
try {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("无法处理该图片")
|
||||
const scale = Math.min(64 / bitmap.width, 64 / bitmap.height)
|
||||
const width = Math.max(1, Math.round(bitmap.width * scale))
|
||||
const height = Math.max(1, Math.round(bitmap.height * scale))
|
||||
context.drawImage(bitmap, Math.round((64 - width) / 2), Math.round((64 - height) / 2), width, height)
|
||||
const result = canvas.toDataURL("image/png")
|
||||
if (result.length > 44_000) throw new Error("处理后的图标过大")
|
||||
return result
|
||||
} finally {
|
||||
bitmap.close()
|
||||
}
|
||||
}
|
||||
const folderLabels: Record<string, string> = {
|
||||
Inbox: "收件箱",
|
||||
Sent: "已发送",
|
||||
@@ -214,6 +155,7 @@ export function MailPage() {
|
||||
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
|
||||
const [exportingMail, setExportingMail] = React.useState(false)
|
||||
const [importingMail, setImportingMail] = React.useState(false)
|
||||
const [, setLastAutoRefreshAt] = React.useState<Date | null>(null)
|
||||
const [bulkPending, setBulkPending] = React.useState(false)
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
||||
@@ -485,7 +427,7 @@ export function MailPage() {
|
||||
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
|
||||
})
|
||||
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) })
|
||||
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } })
|
||||
const cancelScheduledSend = useMutation({
|
||||
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
||||
onMutate: (item) => setCancelingScheduledId(item.id),
|
||||
@@ -497,7 +439,7 @@ export function MailPage() {
|
||||
onSettled: () => setCancelingScheduledId(""),
|
||||
})
|
||||
const createFolder = useMutation({
|
||||
mutationFn: ({ name, icon }: { name: string; icon: string }) => api.createFolder({ mailboxId: activeMailboxId, name, icon }),
|
||||
mutationFn: (name: string) => api.createFolder({ mailboxId: activeMailboxId, name }),
|
||||
onSuccess: (created) => {
|
||||
qc.invalidateQueries({ queryKey: ["folders"] })
|
||||
setFolderDialogOpen(false)
|
||||
@@ -704,6 +646,7 @@ export function MailPage() {
|
||||
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
||||
]).finally(() => {
|
||||
setLastAutoRefreshAt(new Date())
|
||||
window.setTimeout(() => setAutoRefreshing(false), 600)
|
||||
})
|
||||
}, mailRefreshInterval || 30000)
|
||||
@@ -757,12 +700,10 @@ export function MailPage() {
|
||||
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
||||
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
||||
const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading
|
||||
const mailMessagesError = mailView === "external" ? externalMessages.error : mailView === "unknown" ? unknownMessages.error : messages.error
|
||||
const mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage
|
||||
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage
|
||||
const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore
|
||||
const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage()
|
||||
const refetchMessages = () => mailView === "external" ? externalMessages.refetch() : mailView === "unknown" ? unknownMessages.refetch() : messages.refetch()
|
||||
function toggleCompactSelectAll(checked: boolean) {
|
||||
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
||||
}
|
||||
@@ -961,14 +902,6 @@ export function MailPage() {
|
||||
function closeSidebarContextMenu() {
|
||||
setSidebarContextMenu(null)
|
||||
}
|
||||
function runAfterClosingMobileSidebar(action: () => void) {
|
||||
if (!isMobile) {
|
||||
action()
|
||||
return
|
||||
}
|
||||
if (mobileSidebarOpen) setMobileSidebarOpen(false)
|
||||
window.setTimeout(action, 250)
|
||||
}
|
||||
function activateSidebarItem(item: MailMenuItem) {
|
||||
if (item.type === "starred") openStarred()
|
||||
else if (item.type === "scheduled") openScheduled()
|
||||
@@ -1170,6 +1103,7 @@ export function MailPage() {
|
||||
try {
|
||||
await refreshMailData()
|
||||
const refreshedAt = new Date()
|
||||
setLastAutoRefreshAt(refreshedAt)
|
||||
toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` })
|
||||
} catch (error) {
|
||||
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||
@@ -1329,7 +1263,6 @@ export function MailPage() {
|
||||
)}
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="px-1">
|
||||
{!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />}
|
||||
<SidebarGroup>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
@@ -1383,10 +1316,8 @@ export function MailPage() {
|
||||
)}
|
||||
</SidebarMenu>
|
||||
{folders.isLoading && <FolderSkeleton />}
|
||||
{!sidebarCollapsed && folders.isError && <SidebarQueryFailure label="文件夹读取失败" onRetry={() => { void folders.refetch() }} />}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{!sidebarCollapsed && externalImapEnabled && externalMailAccounts.isError && <SidebarQueryFailure label="外部邮箱读取失败" onRetry={() => { void externalMailAccounts.refetch() }} />}
|
||||
{externalAccountItems.length > 0 && <SidebarGroup>
|
||||
{!sidebarCollapsed && <SidebarGroupLabel>外部邮箱</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
@@ -1429,7 +1360,6 @@ export function MailPage() {
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{!sidebarCollapsed && expanded && externalFolders.isLoading && <FolderSkeleton />}
|
||||
{!sidebarCollapsed && expanded && account.id === selectedExternalAccountId && externalFolders.isError && <SidebarQueryFailure label="远端文件夹读取失败" onRetry={() => { void externalFolders.refetch() }} />}
|
||||
</React.Fragment>
|
||||
)})}
|
||||
</SidebarMenu>
|
||||
@@ -1440,7 +1370,7 @@ export function MailPage() {
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />文件夹</SidebarGroupLabel>
|
||||
{canManageFolders && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建文件夹" title="新建文件夹" onClick={() => runAfterClosingMobileSidebar(() => setFolderDialogOpen(true))}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -1500,11 +1430,11 @@ export function MailPage() {
|
||||
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" />标签</SidebarGroupLabel>
|
||||
{canManageCurrentMailboxLabels && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建标签" title="新建标签" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{labelEditMode && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="完成标签编辑" title="完成编辑" onClick={() => setLabelEditMode((v) => !v)}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setLabelEditMode((v) => !v)}>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -1549,7 +1479,7 @@ export function MailPage() {
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
{canReadMail && !sidebarCollapsed && !labels.isLoading && !labels.isError && 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>}
|
||||
{canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && (
|
||||
<SidebarMenuItem>
|
||||
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} />
|
||||
@@ -1557,38 +1487,9 @@ export function MailPage() {
|
||||
)}
|
||||
</SidebarMenu>
|
||||
{labels.isLoading && <FolderSkeleton />}
|
||||
{!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>}
|
||||
</SidebarContent>
|
||||
<SidebarContextMenu
|
||||
state={sidebarContextMenu}
|
||||
canCreate={canManageFolders}
|
||||
canReorder={canOrganizeCurrentMailbox}
|
||||
canDelete={canManageFolders}
|
||||
pending={reorderFolders.isPending || deleteFolder.isPending}
|
||||
onClose={closeSidebarContextMenu}
|
||||
onOpen={(item) => {
|
||||
closeSidebarContextMenu()
|
||||
activateSidebarItem(item)
|
||||
}}
|
||||
onRefresh={() => {
|
||||
closeSidebarContextMenu()
|
||||
void refreshMailData()
|
||||
}}
|
||||
onCreateFolder={() => {
|
||||
closeSidebarContextMenu()
|
||||
runAfterClosingMobileSidebar(() => setFolderDialogOpen(true))
|
||||
}}
|
||||
onMove={(item, action) => {
|
||||
closeSidebarContextMenu()
|
||||
moveSidebarFolder(item, action)
|
||||
}}
|
||||
onDelete={(item) => {
|
||||
closeSidebarContextMenu()
|
||||
runAfterClosingMobileSidebar(() => confirmDeleteFolder(item))
|
||||
}}
|
||||
/>
|
||||
</Sidebar>
|
||||
)
|
||||
|
||||
@@ -1612,12 +1513,8 @@ export function MailPage() {
|
||||
<PermissionEmptyState title="无邮箱前台权限" description="当前账号未开启邮箱前台访问权限。" onOpenSettings={openSettings} />
|
||||
) : !canReadMail ? (
|
||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||
) : mailboxList.isError ? (
|
||||
<MailQueryFailure error={mailboxList.error} onRetry={() => { void mailboxList.refetch() }} />
|
||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
||||
) : mailView === "scheduled" && scheduledSends.isError ? (
|
||||
<MailQueryFailure error={scheduledSends.error} onRetry={() => { void scheduledSends.refetch() }} />
|
||||
) : mailView === "scheduled" && canScheduleMail ? (
|
||||
<ScheduledSendView
|
||||
compact={compactMailLayout}
|
||||
@@ -1630,8 +1527,6 @@ export function MailPage() {
|
||||
/>
|
||||
) : mailView === "scheduled" ? (
|
||||
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
||||
) : mailView === "sendQueue" && sendQueue.isError ? (
|
||||
<MailQueryFailure error={sendQueue.error} onRetry={() => { void sendQueue.refetch() }} />
|
||||
) : mailView === "sendQueue" && canViewSendQueue ? (
|
||||
<SendQueueView
|
||||
compact={compactMailLayout}
|
||||
@@ -1657,8 +1552,6 @@ export function MailPage() {
|
||||
/>
|
||||
) : mailView === "sendQueue" ? (
|
||||
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
|
||||
) : mailMessagesError ? (
|
||||
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
|
||||
) : compactMailLayout ? (
|
||||
<CompactMailView
|
||||
title={viewTitle}
|
||||
@@ -1676,8 +1569,6 @@ export function MailPage() {
|
||||
selectedId={selectedId}
|
||||
selected={selected}
|
||||
detailLoading={detail.isLoading}
|
||||
detailError={detail.error}
|
||||
onRetryDetail={() => { void detail.refetch() }}
|
||||
labels={labelItems}
|
||||
labelPending={addLabel.isPending || removeLabel.isPending}
|
||||
onSelect={openMessage}
|
||||
@@ -1799,8 +1690,7 @@ export function MailPage() {
|
||||
</div>
|
||||
)}
|
||||
{detail.isLoading && <div className="space-y-4 p-6"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-40 w-full" /></div>}
|
||||
{detail.isError && <MailDetailFailure error={detail.error} onRetry={() => { void detail.refetch() }} />}
|
||||
{!detail.isError && selected && <div className="flex h-full min-h-0 flex-col">
|
||||
{selected && <div className="flex h-full min-h-0 flex-col">
|
||||
<div className="border-b p-5">
|
||||
{isTwoPaneMailViewport && (
|
||||
<Button variant="ghost" size="sm" className="-ml-2 mb-3 h-8 px-2 text-[13px] font-normal" onClick={() => setSelectedId(null)}>
|
||||
@@ -1883,9 +1773,7 @@ export function MailPage() {
|
||||
<SendQueueAuditDialog
|
||||
open={!!sendQueueAuditId}
|
||||
loading={sendQueueAudit.isLoading}
|
||||
error={sendQueueAudit.error}
|
||||
events={sendQueueAudit.data?.items || []}
|
||||
onRetry={() => { void sendQueueAudit.refetch() }}
|
||||
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
|
||||
/>
|
||||
<MessageContextMenu
|
||||
@@ -1907,11 +1795,39 @@ export function MailPage() {
|
||||
active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label })
|
||||
}}
|
||||
/>
|
||||
<SidebarContextMenu
|
||||
state={sidebarContextMenu}
|
||||
canCreate={canManageFolders}
|
||||
canReorder={canOrganizeCurrentMailbox}
|
||||
canDelete={canManageFolders}
|
||||
pending={reorderFolders.isPending || deleteFolder.isPending}
|
||||
onClose={closeSidebarContextMenu}
|
||||
onOpen={(item) => {
|
||||
closeSidebarContextMenu()
|
||||
activateSidebarItem(item)
|
||||
}}
|
||||
onRefresh={() => {
|
||||
closeSidebarContextMenu()
|
||||
void refreshMailData()
|
||||
}}
|
||||
onCreateFolder={() => {
|
||||
closeSidebarContextMenu()
|
||||
setFolderDialogOpen(true)
|
||||
}}
|
||||
onMove={(item, action) => {
|
||||
closeSidebarContextMenu()
|
||||
moveSidebarFolder(item, action)
|
||||
}}
|
||||
onDelete={(item) => {
|
||||
closeSidebarContextMenu()
|
||||
confirmDeleteFolder(item)
|
||||
}}
|
||||
/>
|
||||
<CreateFolderDialog
|
||||
open={folderDialogOpen}
|
||||
pending={createFolder.isPending}
|
||||
onOpenChange={setFolderDialogOpen}
|
||||
onCreate={(payload) => createFolder.mutate(payload)}
|
||||
onCreate={(name) => createFolder.mutate(name)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!pendingConfirm}
|
||||
@@ -1919,7 +1835,7 @@ export function MailPage() {
|
||||
description={pendingConfirm?.description}
|
||||
confirmText={pendingConfirm?.confirmText || "确认"}
|
||||
destructive
|
||||
pending={del.isPending || bulkPending || deleteFolder.isPending}
|
||||
pending={del.isPending || bulkPending}
|
||||
onOpenChange={(open) => { if (!open) setPendingConfirm(null) }}
|
||||
onConfirm={() => pendingConfirm?.onConfirm()}
|
||||
/>
|
||||
@@ -1929,7 +1845,7 @@ export function MailPage() {
|
||||
|
||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean, includeUnknown: boolean): MailMenuItem[] {
|
||||
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(), icon: "folder", sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 })
|
||||
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 })
|
||||
for (const item of folders) {
|
||||
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
|
||||
}
|
||||
@@ -1939,7 +1855,7 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
|
||||
folderId: item.id,
|
||||
folderName: item.name,
|
||||
label: folderLabels[item.name] || item.name,
|
||||
icon: isCustomMailFolder(item) ? customFolderIcon(item.icon) : folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
||||
icon: isCustomMailFolder(item) ? <Folder className="h-4 w-4" /> : folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
||||
count: item.name === "Drafts" ? item.totalCount : item.unreadCount,
|
||||
custom: isCustomMailFolder(item),
|
||||
order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name),
|
||||
@@ -1959,13 +1875,6 @@ function isCustomMailFolder(folder: Pick<MailFolder, "name" | "id">) {
|
||||
return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase())
|
||||
}
|
||||
|
||||
function customFolderIcon(iconKey: string | undefined, className = "h-4 w-4") {
|
||||
if (iconKey?.startsWith("data:image/png;base64,")) return <img src={iconKey} alt="" className={cn("shrink-0 object-contain", className)} />
|
||||
const option = customFolderIconOptions.find((item) => item.key === iconKey) || customFolderIconOptions[0]
|
||||
const Icon = option.icon
|
||||
return <Icon className={className} />
|
||||
}
|
||||
|
||||
function compareMailFolders(a: MailFolder, b: MailFolder) {
|
||||
return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name)
|
||||
}
|
||||
@@ -2234,45 +2143,6 @@ function PermissionEmptyState({ title, description, onOpenSettings }: { title: s
|
||||
)
|
||||
}
|
||||
|
||||
function MailQueryFailure({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center p-6" role="alert">
|
||||
<div className="w-full max-w-md rounded-lg border border-destructive/40 bg-destructive/5 p-8 text-center">
|
||||
<div className="text-lg font-semibold text-destructive">邮件数据读取失败</div>
|
||||
<div className="mt-2 break-words text-sm text-muted-foreground">{error.message || "请检查网络连接后重试"}</div>
|
||||
<Button type="button" variant="outline" className="mt-5" onClick={onRetry}>
|
||||
<RefreshCcw className="h-4 w-4" />重新读取
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MailDetailFailure({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="grid h-full min-h-[240px] place-items-center p-6" role="alert">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="font-semibold text-destructive">邮件详情读取失败</div>
|
||||
<div className="mt-2 break-words text-sm text-muted-foreground">{error.message || "请检查网络连接后重试"}</div>
|
||||
<Button type="button" variant="outline" size="sm" className="mt-4" onClick={onRetry}>
|
||||
<RefreshCcw className="h-4 w-4" />重新读取
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarQueryFailure({ label, onRetry }: { label: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="mx-2 my-1 flex min-h-8 items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-2 text-xs text-destructive" role="alert">
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-destructive hover:text-destructive" aria-label={`重试${label}`} title="重新读取" onClick={onRetry}>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</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 }) {
|
||||
const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
|
||||
return (
|
||||
@@ -2508,7 +2378,7 @@ function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SendQueueAuditDialog({ open, loading, error, events, onRetry, onOpenChange }: { open: boolean; loading: boolean; error: Error | null; events: SendQueueAuditEvent[]; onRetry: () => void; onOpenChange: (open: boolean) => void }) {
|
||||
function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
|
||||
@@ -2517,9 +2387,8 @@ function SendQueueAuditDialog({ open, loading, error, events, onRetry, onOpenCha
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-auto pr-1">
|
||||
{loading && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
|
||||
{!loading && error && <div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-center" role="alert"><div className="text-sm font-medium text-destructive">投递记录读取失败</div><div className="mt-1 break-words text-xs text-muted-foreground">{error.message}</div><Button type="button" variant="outline" size="sm" className="mt-4" onClick={onRetry}><RefreshCcw className="h-4 w-4" />重新读取</Button></div>}
|
||||
{!loading && !error && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">暂无投递事件</div>}
|
||||
{!loading && !error && events.length > 0 && (
|
||||
{!loading && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">暂无投递事件</div>}
|
||||
{!loading && events.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="rounded-lg border p-3">
|
||||
@@ -2587,7 +2456,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{movableFolders.map((folder) => (
|
||||
<DropdownMenuItem key={folder.id} onSelect={() => onMoveToFolder(folder.name)}>
|
||||
{isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || <Folder className="h-4 w-4" />}
|
||||
{folderIcons[folder.role] || <Folder className="h-4 w-4" />}
|
||||
<span className="min-w-0 flex-1 truncate">{folderLabels[folder.name] || folder.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -2747,7 +2616,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{movableFolders.map((folder) => (
|
||||
<Button key={folder.id} type="button" variant="ghost" className={itemClass} onClick={() => moveToFolder(folder.name)}>
|
||||
{isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
|
||||
{folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
|
||||
<span className="min-w-0 flex-1 truncate text-left">{folderLabels[folder.name] || folder.name}</span>
|
||||
{folder.totalCount > 0 && <span className="text-xs text-muted-foreground">{folder.totalCount}</span>}
|
||||
</Button>
|
||||
@@ -2792,21 +2661,12 @@ function contextMenuPosition(x: number, y: number) {
|
||||
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
|
||||
}
|
||||
|
||||
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
|
||||
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (name: string) => void }) {
|
||||
const [name, setName] = React.useState("")
|
||||
const [icon, setIcon] = React.useState("auto")
|
||||
const [uploadError, setUploadError] = React.useState("")
|
||||
const iconInputRef = React.useRef<HTMLInputElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setName("")
|
||||
setIcon("auto")
|
||||
setUploadError("")
|
||||
}
|
||||
if (open) setName("")
|
||||
}, [open])
|
||||
const trimmed = name.trim()
|
||||
const suggestedIcon = suggestedFolderIcon(trimmed)
|
||||
const resolvedIcon = icon === "auto" ? suggestedIcon : icon
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(92vw,28rem)] max-w-none">
|
||||
@@ -2817,51 +2677,13 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (trimmed) onCreate({ name: trimmed, icon: resolvedIcon })
|
||||
if (trimmed) onCreate(trimmed)
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-folder-name">文件夹名称</Label>
|
||||
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">图标</legend>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
<Button type="button" variant="outline" size="icon" className={cn("relative size-10 shadow-none", icon === "auto" && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => setIcon("auto")} aria-label={`自动匹配:${customFolderIconOptions.find((item) => item.key === suggestedIcon)?.label || "文件夹"}`} title={`自动匹配:${customFolderIconOptions.find((item) => item.key === suggestedIcon)?.label || "文件夹"}`} aria-pressed={icon === "auto"}>
|
||||
{customFolderIcon(suggestedIcon)}
|
||||
<Sparkles className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-background" />
|
||||
</Button>
|
||||
{customFolderIconOptions.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<Button key={option.key} type="button" variant="outline" size="icon" className={cn("size-10 shadow-none", icon === option.key && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => setIcon(option.key)} aria-label={option.label} title={option.label} aria-pressed={icon === option.key}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Button type="button" variant="outline" size="icon" className={cn("size-10 shadow-none", icon.startsWith("data:image/png;base64,") && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => iconInputRef.current?.click()} aria-label="上传自定义图标" title="上传自定义图标" aria-pressed={icon.startsWith("data:image/png;base64,")}>
|
||||
{icon.startsWith("data:image/png;base64,") ? customFolderIcon(icon) : <Upload className="h-4 w-4" />}
|
||||
</Button>
|
||||
<input
|
||||
ref={iconInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file) return
|
||||
try {
|
||||
setUploadError("")
|
||||
setIcon(await prepareFolderIcon(file))
|
||||
} catch (error) {
|
||||
setUploadError(error instanceof Error ? error.message : "无法处理该图片")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{uploadError && <p role="alert" className="text-xs text-destructive">{uploadError}</p>}
|
||||
</fieldset>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button disabled={!trimmed || pending}>{pending ? "创建中..." : "创建"}</Button>
|
||||
@@ -2887,7 +2709,6 @@ function CompactMailView({
|
||||
selectedId,
|
||||
selected,
|
||||
detailLoading,
|
||||
detailError,
|
||||
labels,
|
||||
labelPending,
|
||||
onSelect,
|
||||
@@ -2895,7 +2716,6 @@ function CompactMailView({
|
||||
onToggleSelected,
|
||||
scheduledDraftIds,
|
||||
onLoadMore,
|
||||
onRetryDetail,
|
||||
onCloseReader,
|
||||
onStar,
|
||||
onReply,
|
||||
@@ -2930,7 +2750,6 @@ function CompactMailView({
|
||||
selectedId: string | null
|
||||
selected?: MailMessage
|
||||
detailLoading: boolean
|
||||
detailError: Error | null
|
||||
labels: MailLabel[]
|
||||
labelPending: boolean
|
||||
onSelect: (id: string | null) => void
|
||||
@@ -2938,7 +2757,6 @@ function CompactMailView({
|
||||
onToggleSelected: (id: string, checked: boolean) => void
|
||||
scheduledDraftIds: Set<string>
|
||||
onLoadMore: () => void
|
||||
onRetryDetail: () => void
|
||||
onCloseReader: () => void
|
||||
onStar: (message: MailMessage) => void
|
||||
onReply: (message: MailMessage) => void
|
||||
@@ -2968,13 +2786,11 @@ function CompactMailView({
|
||||
<CompactMessageDetail
|
||||
selected={selected}
|
||||
loading={detailLoading}
|
||||
error={detailError}
|
||||
labels={labels}
|
||||
labelPending={labelPending}
|
||||
previousMessage={previousMessage}
|
||||
nextMessage={nextMessage}
|
||||
onBack={onCloseReader}
|
||||
onRetry={onRetryDetail}
|
||||
onSelect={onSelect}
|
||||
onStar={onStar}
|
||||
onReply={onReply}
|
||||
@@ -3037,13 +2853,11 @@ function CompactMailView({
|
||||
function CompactMessageDetail({
|
||||
selected,
|
||||
loading,
|
||||
error,
|
||||
labels,
|
||||
labelPending,
|
||||
previousMessage,
|
||||
nextMessage,
|
||||
onBack,
|
||||
onRetry,
|
||||
onSelect,
|
||||
onStar,
|
||||
onReply,
|
||||
@@ -3062,13 +2876,11 @@ function CompactMessageDetail({
|
||||
}: {
|
||||
selected?: MailMessage
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
labels: MailLabel[]
|
||||
labelPending: boolean
|
||||
previousMessage?: MailMessage
|
||||
nextMessage?: MailMessage
|
||||
onBack: () => void
|
||||
onRetry: () => void
|
||||
onSelect: (id: string | null) => void
|
||||
onStar: (message: MailMessage) => void
|
||||
onReply: (message: MailMessage) => void
|
||||
@@ -3148,9 +2960,8 @@ function CompactMessageDetail({
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div className="space-y-4 p-8"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-64 w-full" /></div>}
|
||||
{!loading && error && <MailDetailFailure error={error} onRetry={onRetry} />}
|
||||
{!loading && !error && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground">邮件不存在</div>}
|
||||
{!error && selected && (
|
||||
{!loading && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground">邮件不存在</div>}
|
||||
{selected && (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="w-full px-4 py-4 sm:px-8 sm:py-6">
|
||||
<div className="space-y-5 border-b pb-5">
|
||||
@@ -3871,15 +3682,12 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
return sent
|
||||
},
|
||||
onSuccess: async (_, payloads) => {
|
||||
let draftDeleteFailed = false
|
||||
if (draftId) {
|
||||
try {
|
||||
await api.deleteDraft(draftId)
|
||||
} catch {
|
||||
draftDeleteFailed = true
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功", description: draftDeleteFailed ? "邮件已发送,但原草稿未能自动删除" : undefined })
|
||||
toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功" })
|
||||
setFiles([])
|
||||
setDraftId("")
|
||||
onSent()
|
||||
@@ -4144,14 +3952,6 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
||||
<ComposeField label="主 题">
|
||||
<Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
|
||||
</ComposeField>
|
||||
{defaultSignature.isError && (
|
||||
<div className="flex items-center justify-between gap-3 border-b bg-destructive/5 px-4 py-2 text-xs text-destructive sm:px-6" role="alert">
|
||||
<span>默认签名读取失败</span>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-destructive hover:text-destructive" onClick={() => { void defaultSignature.refetch() }}>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />重新读取
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MailBodyComposer
|
||||
defaultValue={composerText}
|
||||
defaultHtml={draft?.html}
|
||||
@@ -4583,7 +4383,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
<DropdownMenuContent align="start" className="w-64 p-2">
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{composerEmojiOptions.map((emoji) => (
|
||||
<Button key={emoji} type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-lg" aria-label={`插入表情 ${emoji}`} title={`插入 ${emoji}`} onClick={() => insertEmoji(emoji)}>
|
||||
<Button key={emoji} type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-lg" onClick={() => insertEmoji(emoji)}>
|
||||
{emoji}
|
||||
</Button>
|
||||
))}
|
||||
@@ -4695,7 +4495,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
<span className="max-w-48 truncate">{file.name}</span>
|
||||
<span className="text-muted-foreground">{formatBytes(file.size)}</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 rounded-md" aria-label={`移除附件 ${file.name}`} title="移除附件" onClick={() => onRemoveFile(index)}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 rounded-md" onClick={() => onRemoveFile(index)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Badge>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Home, MailQuestion } from "lucide-react"
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
@@ -24,6 +24,6 @@ export function NotFoundPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+121
-193
@@ -32,7 +32,6 @@ import { useToast } from "@/hooks/use-toast"
|
||||
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
|
||||
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
|
||||
type RetryableQuery = { isError: boolean; error: Error | null; refetch: () => Promise<unknown> }
|
||||
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
||||
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
|
||||
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
|
||||
@@ -179,7 +178,7 @@ export function ProfilePage() {
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
|
||||
const createSignature = useMutation({
|
||||
mutationFn: (form: FormData) => api.createSignature({ mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已保存" }) },
|
||||
@@ -195,7 +194,7 @@ export function ProfilePage() {
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) },
|
||||
onError: (error) => toast({ title: "设置失败", description: error.message }),
|
||||
})
|
||||
const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||
const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) } })
|
||||
const createRule = useMutation({
|
||||
mutationFn: (payload: {
|
||||
mailboxId: string
|
||||
@@ -217,7 +216,7 @@ export function ProfilePage() {
|
||||
},
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
|
||||
const updateRule = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<RuleCreatePayload> }) => api.updateRule(id, payload),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) },
|
||||
@@ -244,7 +243,7 @@ export function ProfilePage() {
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) },
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) } })
|
||||
const createLabel = useMutation({
|
||||
mutationFn: (form: FormData) => api.createLabel({ mailboxId: activeMailboxId, name: String(form.get("name") || ""), color: String(form.get("color") || "") }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已创建" }) },
|
||||
@@ -434,15 +433,6 @@ export function ProfilePage() {
|
||||
</div>
|
||||
)
|
||||
function renderTab() {
|
||||
const failedQueries = visibleTabQueries().filter((query) => query.isError && query.error)
|
||||
if (failedQueries.length > 0) {
|
||||
return (
|
||||
<ProfileQueryFailure
|
||||
errors={failedQueries.map((query) => query.error!)}
|
||||
onRetry={() => { void Promise.all(failedQueries.map((query) => query.refetch())) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (tab === "profile") return (
|
||||
<AccountSettingsSection
|
||||
activeTab={accountTab}
|
||||
@@ -516,22 +506,6 @@ export function ProfilePage() {
|
||||
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
|
||||
return null
|
||||
}
|
||||
function visibleTabQueries(): RetryableQuery[] {
|
||||
if (tab === "profile") {
|
||||
if (accountTab === "security") return []
|
||||
if (accountTab === "account") return [mailboxes, accountStats]
|
||||
if (accountTab === "mail") return [mailboxes, labels, signatures]
|
||||
return [mailboxes, publicSettings]
|
||||
}
|
||||
if (tab === "mailboxes") return [mailboxes, mailboxApplyOptions, publicSettings, externalImapAccounts, externalRunFolders, externalSyncRuns]
|
||||
if (tab === "contacts") return [contacts]
|
||||
if (tab === "cleanup" || tab === "cleanupQueue") return [mailboxes, mailboxStats]
|
||||
if (tab === "rules") return [mailboxes, rules, ruleForwarding, labels]
|
||||
if (tab === "blocked") return [mailboxes, blocked, blockedStats]
|
||||
if (tab === "stats") return [mailboxes, dashboardStats]
|
||||
if (tab === "apiTokens") return [apiTokens]
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function contentFrameClass(tab: Tab) {
|
||||
@@ -703,20 +677,7 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileQueryFailure({ errors, onRetry }: { errors: Error[]; onRetry: () => void }) {
|
||||
const messages = Array.from(new Set(errors.map((error) => error.message).filter(Boolean)))
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-8 text-center" role="alert">
|
||||
<div className="text-base font-semibold text-destructive">页面数据读取失败</div>
|
||||
<div className="mt-2 break-words text-sm text-muted-foreground">{messages.join(";") || "请检查网络连接后重试"}</div>
|
||||
<Button type="button" variant="outline" className="mt-5" onClick={onRetry}>
|
||||
<RefreshCcw className="h-4 w-4" />重新读取
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountTabSection({ user, profile, stats, showStats, displayMode, onDisplayModeChange, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
|
||||
function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
|
||||
const accountName = user.email
|
||||
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
|
||||
const storageBytes = stats?.storageBytes || 0
|
||||
@@ -724,35 +685,26 @@ function AccountTabSection({ user, profile, stats, showStats, displayMode, onDis
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard title="账号信息">
|
||||
<form className="space-y-5" onSubmit={(event) => { event.preventDefault(); profile.mutate(new FormData(event.currentTarget)) }}>
|
||||
<div className="space-y-5">
|
||||
<InfoLine label="主登录邮箱" value={accountName} />
|
||||
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||
<Label htmlFor="profile-display-name" className="text-base font-normal text-muted-foreground">显示名称</Label>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:w-[320px] sm:flex-row">
|
||||
<Input id="profile-display-name" name="displayName" defaultValue={user.displayName} maxLength={80} required className="h-9" />
|
||||
<Button type="submit" size="sm" disabled={profile.isPending}>{profile.isPending ? "保存中..." : "保存"}</Button>
|
||||
<Label className="text-base font-normal text-muted-foreground">时区</Label>
|
||||
<Select defaultValue="Asia/Shanghai">
|
||||
<SelectTrigger className="h-[29px] sm:ml-auto sm:w-[236px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Asia/Shanghai">Asia/Shanghai (UTC+8)</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">Asia/Tokyo (UTC+9)</SelectItem>
|
||||
<SelectItem value="Asia/Singapore">Asia/Singapore (UTC+8)</SelectItem>
|
||||
<SelectItem value="UTC">UTC (UTC+0)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="邮件列表显示">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" role="group" aria-label="邮件列表显示模式">
|
||||
{(["detailed", "compact"] as DisplayMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={cn("h-9 rounded-md px-3 text-sm font-medium transition-colors", displayMode === mode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
|
||||
aria-pressed={displayMode === mode}
|
||||
onClick={() => onDisplayModeChange(mode)}
|
||||
>
|
||||
{mode === "detailed" ? "标准布局" : "紧凑布局"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{showStats && <SettingsCard title="存储容量" action={<Button type="button" variant="outline" size="sm" onClick={onOpenCleanup}>邮件清理</Button>}>
|
||||
<SettingsCard title="存储容量" action={<Button type="button" variant="outline" size="sm" onClick={onOpenCleanup}>邮件清理</Button>}>
|
||||
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{quotaBytes > 0 ? `${formatBytes(storageBytes)} / ${formatBytes(quotaBytes)}` : formatBytes(storageBytes)}</span>
|
||||
<span>{quotaBytes > 0 ? `${quotaPct}%` : "不限"}</span>
|
||||
@@ -760,7 +712,7 @@ function AccountTabSection({ user, profile, stats, showStats, displayMode, onDis
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${quotaBytes > 0 ? quotaPct : 12}%` }} />
|
||||
</div>
|
||||
</SettingsCard>}
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground">实时按当前账号配置计算</span>}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
@@ -910,8 +862,8 @@ function MailPreferencesSection({
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{!item.isDefault && <Button type="button" variant="outline" size="sm" disabled={signaturesPending} onClick={() => onSetDefaultSignature(item.id)}>设为默认</Button>}
|
||||
<Button type="button" variant="ghost" size="icon" className="size-8" aria-label={`编辑签名 ${item.name}`} title="编辑签名" onClick={() => setEditingSignature(item)}><PencilLine className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-8 text-destructive" aria-label={`删除签名 ${item.name}`} title="删除签名" onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", destructive: true, onConfirm: () => { onDeleteSignature(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-8" onClick={() => setEditingSignature(item)}><PencilLine className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", destructive: true, onConfirm: () => { onDeleteSignature(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -971,7 +923,7 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="font-semibold">{user.email}</div>
|
||||
<div className="text-sm text-muted-foreground">当前已登录账号</div>
|
||||
<div className="text-sm text-muted-foreground">上次登录:刚刚</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
@@ -1047,10 +999,27 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="临时发信申请">
|
||||
<div className="space-y-3">
|
||||
{[selectedRequestRow(user.email, "已批准", "6小时"), selectedRequestRow(user.email, "已过期", "24小时")].map((item, index) => (
|
||||
<div key={`${item.status}-${index}`} className="rounded-lg border px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">{item.email}</span>
|
||||
<Badge variant="secondary">{item.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">申请时长:{item.duration} · 原因:临时客户端发信测试</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function selectedRequestRow(email: string, status: string, duration: string) {
|
||||
return { email, status, duration }
|
||||
}
|
||||
|
||||
function CleanupQueueSection({ mailbox, stats }: { mailbox?: Mailbox; stats?: MailStats }) {
|
||||
const rows = (stats?.byFolder || []).filter((item) => item.role === "trash" || item.role === "spam").map((item) => ({ name: folderLabel(item.folder), count: item.count, bytes: item.bytes }))
|
||||
return (
|
||||
@@ -1130,21 +1099,10 @@ function MailboxManagement({
|
||||
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
|
||||
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
|
||||
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
|
||||
const [verifiedEmailsExpanded, setVerifiedEmailsExpanded] = React.useState(false)
|
||||
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
|
||||
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
|
||||
const verifiedEmailItems = React.useMemo(() => [...(forwarding.data?.verifiedEmails || [])].sort((a, b) => forwardingTargetCollator.compare(a.email, b.email)), [forwarding.data?.verifiedEmails])
|
||||
const verifiedEmails = React.useMemo(() => sortForwardingTargets(verifiedEmailItems.filter((item) => item.verified).map((item) => item.email)), [verifiedEmailItems])
|
||||
const pendingVerifiedEmailItems = React.useMemo(() => verifiedEmailItems.filter((item) => !item.verified), [verifiedEmailItems])
|
||||
const completedVerifiedEmailItems = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified), [verifiedEmailItems])
|
||||
const normalizedVerifiedEmailDraft = verifiedEmailDraft.trim().toLowerCase()
|
||||
const matchingPendingVerifiedEmailItems = React.useMemo(() => normalizedVerifiedEmailDraft
|
||||
? pendingVerifiedEmailItems.filter((item) => item.email.toLowerCase().includes(normalizedVerifiedEmailDraft))
|
||||
: pendingVerifiedEmailItems, [normalizedVerifiedEmailDraft, pendingVerifiedEmailItems])
|
||||
const matchingCompletedVerifiedEmailItems = React.useMemo(() => normalizedVerifiedEmailDraft
|
||||
? completedVerifiedEmailItems.filter((item) => item.email.toLowerCase().includes(normalizedVerifiedEmailDraft))
|
||||
: completedVerifiedEmailItems, [completedVerifiedEmailItems, normalizedVerifiedEmailDraft])
|
||||
const verifiedEmailDraftExists = verifiedEmailItems.some((item) => item.email.toLowerCase() === normalizedVerifiedEmailDraft)
|
||||
const hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified)
|
||||
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
|
||||
const next: Record<string, string[]> = {}
|
||||
@@ -1361,8 +1319,9 @@ function MailboxManagement({
|
||||
</section>
|
||||
|
||||
<section id="mailbox-forwarding-section" className="rounded-lg border bg-card px-6 py-5">
|
||||
<div className="mb-5 flex items-center gap-4">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold leading-7">邮件转发</h2>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setVerifiedDialogOpen(true)}>管理验证邮箱</Button>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/20 px-5 py-5">
|
||||
<div className="mb-3 text-sm font-medium">账号级转发</div>
|
||||
@@ -1372,14 +1331,20 @@ function MailboxManagement({
|
||||
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="mt-5 h-auto w-full justify-start gap-3 px-4 py-3 text-left font-normal shadow-none" onClick={() => setVerifiedDialogOpen(true)}>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-emerald-50 text-emerald-700"><MailCheck className="h-4 w-4" /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">管理验证邮箱</span>
|
||||
<span className="block truncate text-sm text-muted-foreground">已验证 {completedVerifiedEmailItems.length} 个{pendingVerifiedEmailItems.length > 0 ? `,待验证 ${pendingVerifiedEmailItems.length} 个` : ""}</span>
|
||||
{verifiedEmailItems.length > 0 && (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{verifiedEmailItems.map((item) => {
|
||||
const tone = forwardingEmailTone(item)
|
||||
return (
|
||||
<span key={item.id} className={cn("inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1 text-sm", tone.chipClass)}>
|
||||
<span className={cn("size-2 shrink-0 rounded-full", tone.dotClass)} />
|
||||
<span className="min-w-0 truncate">{item.email} · {tone.shortLabel}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 -rotate-90 text-muted-foreground" />
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground">暂未添加验证邮箱,请先点击「管理验证邮箱」添加。</p>}
|
||||
<p className="mt-3 text-sm text-muted-foreground">提示:点击邮箱列表中的「转发」按钮,可在账号级目标之外追加该邮箱自己的转发目标。</p>
|
||||
</section>
|
||||
|
||||
@@ -1473,58 +1438,45 @@ function MailboxManagement({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={verifiedDialogOpen} onOpenChange={(open) => {
|
||||
setVerifiedDialogOpen(open)
|
||||
if (!open) setVerifiedEmailDraft("")
|
||||
}}>
|
||||
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
|
||||
<DialogHeader className="px-8 pt-8">
|
||||
<DialogTitle className="text-2xl leading-8">验证邮箱管理</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-8 pt-4 text-sm leading-6 text-muted-foreground">
|
||||
搜索已添加地址,或输入新的外部邮箱并发送验证邮件。
|
||||
<div className="px-8 pt-5 text-[17px] leading-8 text-muted-foreground">
|
||||
添加并验证外部邮箱地址后,才能用作转发目标。这里只展示投递状态摘要,不展示验证邮件内容。
|
||||
</div>
|
||||
<form className="grid gap-3 px-8 pt-6 sm:grid-cols-[minmax(0,1fr)_96px]" onSubmit={submitVerifiedEmail}>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input type="email" value={verifiedEmailDraft} onChange={(event) => setVerifiedEmailDraft(event.target.value)} className="h-12 pl-10 text-base shadow-none" placeholder="搜索或输入新邮箱" disabled={forwardingBusy} aria-label="搜索或输入新邮箱" />
|
||||
</div>
|
||||
<Button className="h-12 px-0 text-base" disabled={forwardingBusy || !verifiedEmailDraft.trim() || verifiedEmailDraftExists}>{addVerifiedEmail.isPending ? "添加中" : verifiedEmailDraftExists ? "已添加" : "添加"}</Button>
|
||||
<Input type="email" value={verifiedEmailDraft} onChange={(event) => setVerifiedEmailDraft(event.target.value)} className="h-12 text-base shadow-none" placeholder="输入邮箱地址" disabled={forwardingBusy} />
|
||||
<Button className="h-12 px-0 text-base" disabled={forwardingBusy || !verifiedEmailDraft.trim()}>{addVerifiedEmail.isPending ? "添加中" : "添加"}</Button>
|
||||
</form>
|
||||
<div className="mx-8 mt-6 max-h-[380px] space-y-4 overflow-y-auto pr-1">
|
||||
{pendingVerifiedEmailItems.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<div className="border-b bg-muted/30 px-4 py-3 text-sm font-medium">待验证 ({pendingVerifiedEmailItems.length})</div>
|
||||
{matchingPendingVerifiedEmailItems.map((item) => (
|
||||
<VerifiedEmailRow key={item.id} item={item} busy={forwardingBusy} onResend={resendVerification} onRemove={removeVerifiedEmail} />
|
||||
))}
|
||||
{matchingPendingVerifiedEmailItems.length === 0 && <div className="px-4 py-8 text-center text-sm text-muted-foreground">待验证邮箱中没有匹配地址</div>}
|
||||
<div className="mx-8 mt-6 max-h-[360px] overflow-y-auto rounded-lg border">
|
||||
{verifiedEmailItems.map((item) => (
|
||||
<div key={item.id} className="grid min-h-[82px] gap-3 border-b px-4 py-4 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold leading-6">{item.email}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{item.verified ? `已验证 - ${formatDateTime(item.verifiedAt || item.createdAt)}` : "待验证"}</div>
|
||||
{!item.verified && (
|
||||
<div className={cn("mt-1 text-sm leading-5", forwardingEmailTone(item).detailClass)}>
|
||||
{forwardingEmailStatusText(item)}
|
||||
</div>
|
||||
)}
|
||||
{completedVerifiedEmailItems.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<Button type="button" variant="ghost" className="h-12 w-full justify-start gap-3 rounded-none px-4 font-normal" onClick={() => setVerifiedEmailsExpanded((value) => !value)} aria-expanded={verifiedEmailsExpanded || !!normalizedVerifiedEmailDraft}>
|
||||
<MailCheck className="h-4 w-4 text-emerald-600" />
|
||||
<span className="flex-1 text-sm font-medium">已验证邮箱 ({completedVerifiedEmailItems.length})</span>
|
||||
{verifiedEmailsExpanded || normalizedVerifiedEmailDraft ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<span className={cn("size-2.5 rounded-full", forwardingEmailTone(item).dotClass)} />
|
||||
{!item.verified && (
|
||||
<Button type="button" variant="outline" className="h-10 px-4" disabled={forwardingBusy} onClick={() => resendVerification(item)}>重发</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" className="h-10 px-4 text-destructive hover:text-destructive" disabled={forwardingBusy} onClick={() => removeVerifiedEmail(item.id, item.email)} aria-label={`移除 ${item.email}`}>
|
||||
删除
|
||||
</Button>
|
||||
{(verifiedEmailsExpanded || !!normalizedVerifiedEmailDraft) && (
|
||||
<div className="border-t">
|
||||
{matchingCompletedVerifiedEmailItems.map((item) => (
|
||||
<VerifiedEmailRow key={item.id} item={item} busy={forwardingBusy} onResend={resendVerification} onRemove={removeVerifiedEmail} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{matchingCompletedVerifiedEmailItems.length === 0 && <div className="px-4 py-8 text-center text-sm text-muted-foreground">已验证邮箱中没有匹配地址</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{verifiedEmailItems.length === 0 && <div className="rounded-lg border py-10 text-center text-sm text-muted-foreground">暂无验证邮箱</div>}
|
||||
{normalizedVerifiedEmailDraft && !verifiedEmailDraftExists && matchingPendingVerifiedEmailItems.length === 0 && matchingCompletedVerifiedEmailItems.length === 0 && verifiedEmailItems.length > 0 && (
|
||||
<div className="rounded-md bg-muted/40 px-4 py-3 text-sm text-muted-foreground">没有匹配地址,可点击「添加」发送验证邮件。</div>
|
||||
)}
|
||||
{verifiedEmailItems.length === 0 && <div className="py-10 text-center text-sm text-muted-foreground">暂无验证邮箱</div>}
|
||||
</div>
|
||||
<DialogFooter className="border-t px-8 py-6">
|
||||
<Button type="button" variant="outline" className="h-12 px-8 text-base" onClick={() => { setVerifiedDialogOpen(false); setVerifiedEmailDraft("") }}>关闭</Button>
|
||||
<Button type="button" variant="outline" className="h-12 px-8 text-base" onClick={() => setVerifiedDialogOpen(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1824,7 +1776,7 @@ function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedL
|
||||
autoFocus
|
||||
/>
|
||||
{query && (
|
||||
<Button type="button" variant="ghost" size="icon" className="absolute right-0.5 top-0.5 size-7 text-muted-foreground" aria-label="清除搜索" title="清除搜索" onClick={() => setQuery("")}>
|
||||
<Button type="button" variant="ghost" size="icon" className="absolute right-0.5 top-0.5 size-7 text-muted-foreground" onClick={() => setQuery("")}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -1869,55 +1821,35 @@ function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedL
|
||||
)
|
||||
}
|
||||
|
||||
function VerifiedEmailRow({ item, busy, onResend, onRemove }: {
|
||||
item: ForwardingVerifiedEmail
|
||||
busy: boolean
|
||||
onResend: (item: ForwardingVerifiedEmail) => void
|
||||
onRemove: (id: string, email: string) => void
|
||||
}) {
|
||||
const tone = forwardingEmailTone(item)
|
||||
return (
|
||||
<div className="grid min-h-[72px] gap-3 border-b px-4 py-3 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("size-2 shrink-0 rounded-full", tone.dotClass)} />
|
||||
<span className="min-w-0 truncate text-sm font-semibold" title={item.email}>{item.email}</span>
|
||||
</div>
|
||||
<div className={cn("mt-1 pl-4 text-xs leading-5", item.verified ? "text-muted-foreground" : tone.detailClass)}>
|
||||
{item.verified ? `验证于 ${formatDateTime(item.verifiedAt || item.createdAt)}` : forwardingEmailStatusText(item)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
{!item.verified && (
|
||||
<Button type="button" variant="outline" size="sm" className="h-9" disabled={busy} onClick={() => onResend(item)}>重发</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="sm" className="h-9 text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={busy} onClick={() => onRemove(item.id, item.email)} aria-label={`删除 ${item.email}`}>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function forwardingEmailTone(item: ForwardingVerifiedEmail) {
|
||||
if (item.verified) {
|
||||
return {
|
||||
shortLabel: "已验证",
|
||||
dotClass: "bg-emerald-500",
|
||||
chipClass: "bg-emerald-100 text-emerald-800",
|
||||
detailClass: "text-emerald-700",
|
||||
}
|
||||
}
|
||||
if (item.deliveryStatus === "failed") {
|
||||
return {
|
||||
shortLabel: "发送失败",
|
||||
dotClass: "bg-destructive",
|
||||
chipClass: "bg-destructive/10 text-destructive",
|
||||
detailClass: "text-destructive",
|
||||
}
|
||||
}
|
||||
if (item.deliveryStatus === "delivered") {
|
||||
return {
|
||||
shortLabel: "待验证",
|
||||
dotClass: "bg-amber-500",
|
||||
chipClass: "bg-amber-100 text-amber-800",
|
||||
detailClass: "text-amber-700",
|
||||
}
|
||||
}
|
||||
return {
|
||||
shortLabel: "待验证",
|
||||
dotClass: "bg-muted-foreground",
|
||||
chipClass: "bg-muted text-foreground",
|
||||
detailClass: "text-foreground",
|
||||
}
|
||||
}
|
||||
@@ -2005,7 +1937,7 @@ function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelec
|
||||
<div className="text-muted-foreground">用户名</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="truncate text-right sm:text-left">{selected.address}</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="复制邮箱地址" title="复制邮箱地址" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="text-muted-foreground">密码</div>
|
||||
<div>邮箱登录密码</div>
|
||||
@@ -2029,7 +1961,7 @@ function ClientConfigRow({ label, value, security, onCopy }: { label: string; va
|
||||
<code className="truncate rounded border bg-background px-2 py-1 text-xs">{value}</code>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="text-xs font-medium text-emerald-600">{security}</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label={`复制${label}`} title={`复制${label}`} onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2196,8 +2128,8 @@ function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }
|
||||
<div className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-8" aria-label={`复制联系人邮箱 ${item.email}`} title="复制邮箱" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-8 text-destructive" aria-label={`删除联系人 ${item.email}`} title="删除联系人" onClick={() => setPendingConfirm({ title: "删除联系人?", description: `${item.email} 将从联系人列表中移除。`, confirmText: "删除联系人", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除联系人?", description: `${item.email} 将从联系人列表中移除。`, confirmText: "删除联系人", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -2273,7 +2205,7 @@ function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenCh
|
||||
<Button className="h-9 w-full rounded-md px-4 text-sm font-normal sm:w-auto" onClick={() => { setEditingRule(null); onOpenChange(true) }}>新建规则</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} mailboxLabel={item.mailboxId ? mailboxes.find((mailbox) => mailbox.id === item.mailboxId)?.address || "指定邮箱" : "全部邮箱"} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
|
||||
{items.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
|
||||
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
|
||||
</div>
|
||||
<RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
|
||||
@@ -2336,13 +2268,13 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!flex h-dvh w-screen max-w-none flex-col gap-0 overflow-hidden border-0 p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)] sm:border">
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-4 pr-16 text-left sm:px-8 sm:py-6 sm:pr-16">
|
||||
<DialogContent className="flex h-svh w-screen max-w-none gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)]">
|
||||
<DialogHeader className="border-b px-4 py-4 text-left sm:px-8 sm:py-6">
|
||||
<DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
|
||||
<Field label="名称"><Input className="h-11 sm:h-9" value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
|
||||
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
|
||||
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -2355,20 +2287,18 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="grid grid-cols-2 gap-2 rounded-md border p-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto] md:gap-3 md:border-0 md:p-0">
|
||||
<div key={index} className="grid gap-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto]">
|
||||
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
||||
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
||||
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input className="col-span-2 h-11 md:col-span-1 md:h-9" type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||
<div className="col-span-2 flex justify-end gap-2 md:contents">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 text-muted-foreground md:size-9" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="outline" size="icon" className="size-11 md:size-9" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2378,16 +2308,14 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
||||
<div className="text-sm">执行以下动作</div>
|
||||
<div className="space-y-3">
|
||||
{actions.map((action, index) => (
|
||||
<div key={index} className="grid gap-2 rounded-md border p-3 md:grid-cols-[180px_minmax(0,1fr)_auto_auto] md:gap-3 md:border-0 md:p-0">
|
||||
<div key={index} className={cn("grid gap-3", action.type === "forward" ? "md:grid-cols-[180px_minmax(0,1fr)_auto]" : "md:grid-cols-[180px_minmax(0,1fr)_auto_auto]")}>
|
||||
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
|
||||
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="min-w-0"><RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} /></div>
|
||||
<div className="flex justify-end gap-2 md:contents">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 text-muted-foreground md:size-9" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="outline" size="icon" className="size-11 md:size-9" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
|
||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2398,13 +2326,13 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
||||
<div className="space-y-4">
|
||||
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
|
||||
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
|
||||
<Info className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 gap-2 border-t px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 sm:px-8 sm:py-5 [&>button]:min-h-11 [&>button]:w-full sm:[&>button]:min-h-9 sm:[&>button]:w-auto">
|
||||
<DialogFooter className="gap-2 border-t px-4 py-4 sm:px-8 sm:py-5 [&>button]:w-full sm:[&>button]:w-auto">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
|
||||
</DialogFooter>
|
||||
@@ -2462,13 +2390,15 @@ function verifiedRuleForwardTargets(value: string, verifiedEmails: string[]) {
|
||||
|
||||
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
|
||||
const id = React.useId()
|
||||
return <div className="flex min-w-0 items-start gap-3"><Checkbox id={id} className="mt-0.5" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-sm font-medium leading-5 sm:text-base sm:leading-6">{label}</Label></div>
|
||||
return <div className="flex items-center gap-3"><Checkbox id={id} checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-base font-medium">{label}</Label></div>
|
||||
}
|
||||
|
||||
function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; mailboxLabel: string; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
|
||||
function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false)
|
||||
const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
|
||||
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作"
|
||||
const moveDirection = index === 0 ? "down" : "up"
|
||||
const canMove = count > 1
|
||||
return (
|
||||
<div className="grid min-h-[110px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20">
|
||||
<div className="min-w-0 space-y-1">
|
||||
@@ -2477,14 +2407,12 @@ function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onTog
|
||||
<span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
|
||||
</div>
|
||||
<div className="grid text-sm leading-6 text-muted-foreground">
|
||||
<p className="truncate"><span className="text-muted-foreground">适用:</span> {mailboxLabel}</p>
|
||||
<p className="truncate"><span className="text-muted-foreground">条件:</span> {conditionText}</p>
|
||||
<p className="truncate"><span className="text-muted-foreground">动作:</span> {actionText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || index === 0} onClick={() => onMove("up")} aria-label="上移" title="上移"><ChevronUp className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || index === count - 1} onClick={() => onMove("down")} aria-label="下移" title="下移"><ChevronDown className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || !canMove} onClick={() => onMove(moveDirection)} aria-label={moveDirection === "up" ? "上移" : "下移"} title={moveDirection === "up" ? "上移" : "下移"}>{moveDirection === "up" ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</Button>
|
||||
<span className="mx-1 h-6 w-px bg-border" />
|
||||
<Button type="button" variant="ghost" size="icon" className={cn("size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button>
|
||||
@@ -2572,7 +2500,7 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
|
||||
<div className="truncate text-sm font-semibold text-foreground">{item.email}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`移除拦截规则 ${item.email}`} title="移除拦截规则" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <EmptyState icon={<ShieldCheck />} text="没有被拦截的邮件" description="当前没有发件人拦截规则,所有邮件都会按正常规则投递。" />}
|
||||
@@ -2843,10 +2771,10 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
|
||||
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onToggleTheme}>
|
||||
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useToast } from "@/hooks/use-toast"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { TurnstileBox } from "@/components/turnstile-box"
|
||||
import { validatePasswordConfirm } from "@/lib/validation"
|
||||
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||
|
||||
export function RegisterPage() {
|
||||
const me = useMe()
|
||||
@@ -58,10 +57,8 @@ export function RegisterPage() {
|
||||
})
|
||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||
if (me.data?.user) return <Navigate to="/" replace />
|
||||
if (publicSettings.isLoading) return <AuthLoading />
|
||||
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
@@ -106,6 +103,7 @@ export function RegisterPage() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName" className="text-sm font-medium">显示名称</Label>
|
||||
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
|
||||
<p className="text-xs leading-5 text-muted-foreground">显示名称注册后不可自行修改,如需更换请联系管理员。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||
@@ -132,6 +130,6 @@ export function RegisterPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-29
@@ -1,12 +1,8 @@
|
||||
import path from "node:path"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig, loadEnv } from "vite"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "")
|
||||
const apiTarget = env.VITE_API_TARGET || "http://localhost:8080"
|
||||
|
||||
return {
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -16,29 +12,8 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": apiTarget,
|
||||
"/healthz": apiTarget,
|
||||
"/api": "http://localhost:8080",
|
||||
"/healthz": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: "prosemirror",
|
||||
test: /node_modules[\\/]prosemirror-/,
|
||||
priority: 30,
|
||||
},
|
||||
{
|
||||
name: "tiptap",
|
||||
test: /node_modules[\\/]@tiptap/,
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
| NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 |
|
||||
| NSX-20260807-008 | 2026-08-07 | 已完成 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||
| NSX-20260807-009 | 2026-08-07 | 已完成 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 随 v1.2.19 发布 |
|
||||
| NSX-20260807-010 | 2026-08-07 | 已完成 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 随 v1.2.19 发布,线上 DKIM 已验收通过 |
|
||||
| NSX-20260807-010 | 2026-08-07 | 待验收 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 已发布,待线上实发验收 |
|
||||
| NSX-20260807-011 | 2026-08-07 | 已完成 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||
| NSX-20260807-012 | 2026-08-07 | 已完成 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||
@@ -336,16 +336,16 @@
|
||||
| --- | --- |
|
||||
| 编号 | NSX-20260807-010 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 状态 | 已完成 |
|
||||
| 状态 | 待验收 |
|
||||
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
|
||||
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
|
||||
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
|
||||
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
|
||||
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
|
||||
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
|
||||
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上 DKIM 已验收通过。 |
|
||||
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上仍需升级后重新发信,确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
||||
| 目标版本 | v1.2.19 |
|
||||
| 发布状态 | 已随 v1.2.19 发布,线上 DKIM 已验收通过。 |
|
||||
| 发布状态 | 已随 v1.2.19 发布,待服务器升级后实发验收。 |
|
||||
|
||||
### 历史
|
||||
|
||||
@@ -354,7 +354,6 @@
|
||||
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
|
||||
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
|
||||
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
||||
| 2026-08-08 | 用户确认 DKIM 已通过,状态流转为已完成。 |
|
||||
|
||||
## NSX-20260807-011
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -81,8 +81,8 @@ importers:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
dompurify:
|
||||
specifier: 3.4.13
|
||||
version: 3.4.13
|
||||
specifier: 3.4.12
|
||||
version: 3.4.12
|
||||
lucide-react:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(react@18.3.1)
|
||||
@@ -1022,8 +1022,8 @@ packages:
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
dompurify@3.4.13:
|
||||
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
|
||||
dompurify@3.4.12:
|
||||
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
|
||||
|
||||
electron-to-chromium@1.5.375:
|
||||
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
|
||||
@@ -2447,7 +2447,7 @@ snapshots:
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dompurify@3.4.13:
|
||||
dompurify@3.4.12:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
|
||||
Reference in New Issue
Block a user