Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cb3f13b02 | |||
| d2cfad3cc4 | |||
| dbd5b95143 | |||
| 131421a0f1 | |||
| 2a26a3b127 | |||
| aeaa151e90 |
@@ -0,0 +1,3 @@
|
|||||||
|
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
|
||||||
|
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
|
||||||
|
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
|
||||||
|
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
|
||||||
|
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
|
||||||
|
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
|
||||||
|
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
|
||||||
|
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
|
||||||
|
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
|
||||||
|
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- 修复 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 和完整发信回归测试。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- 优化转发验证完成页:移除“返回邮箱”入口,外部收件人确认 Netflix、ChatGPT 等验证码转发授权后不会进入邮箱登录页,只显示验证结果和关闭页面提示。
|
||||||
|
- 合并验证邮箱搜索与添加入口:输入内容会实时筛选已添加地址,输入新邮箱时可直接发送验证邮件,已存在地址会明确显示为“已添加”。
|
||||||
|
- 重整验证邮箱管理列表:待验证邮箱置顶展示,已验证邮箱按数字和字母排序并聚合为可折叠分组,邮箱数量较多时仍便于查找和管理。
|
||||||
|
- 精简邮件转发主页面:不再平铺全部验证邮箱标签,改为显示已验证与待验证数量汇总,点击即可进入管理列表。
|
||||||
|
- 补充验证完成页回归测试,确保页面不再出现邮箱首页或登录入口,并完成前端构建、组件规范、后端全量测试与静态检查。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- 精简邮件转发页面入口,移除右上角重复的“管理验证邮箱”按钮。
|
||||||
|
- 保留下方汇总入口并统一命名为“管理验证邮箱”,继续显示已验证与待验证邮箱数量。
|
||||||
|
- 没有验证邮箱时仍显示该入口,用户可直接进入添加和验证邮箱。
|
||||||
@@ -289,18 +289,20 @@ jobs:
|
|||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
for index, entry in enumerate(entries, 1):
|
for index, entry in enumerate(entries, 1):
|
||||||
parts = re.split(r"[,;。]", entry, maxsplit=1)
|
parts = re.split(r"[::]", entry, maxsplit=1)
|
||||||
|
if len(parts) == 1:
|
||||||
|
parts = re.split(r"[,;。]", entry, maxsplit=1)
|
||||||
title = parts[0].strip()
|
title = parts[0].strip()
|
||||||
description = parts[1].strip() if len(parts) > 1 else ""
|
description = parts[1].strip() if len(parts) > 1 else ""
|
||||||
section = f"<b>{index:02d} {html.escape(title)}</b>"
|
section = f"<b>{index:02d} · {html.escape(title)}</b>"
|
||||||
if description:
|
if description:
|
||||||
section += "\n" + html.escape(description.rstrip("。") + "。")
|
section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
|
||||||
sections.append(section)
|
sections.append(section)
|
||||||
|
|
||||||
tag = os.environ["RELEASE_TAG"]
|
tag = os.environ["RELEASE_TAG"]
|
||||||
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n新版本现已发布\n\n<b>本次更新</b>\n\n"
|
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n<i>新版本现已发布</i>\n\n<b>更新内容 · {len(sections)} 项</b>\n\n"
|
||||||
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
|
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
|
||||||
footer = f'\n\n🔗 <a href="{release_url}">查看本次更新</a>'
|
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
|
||||||
available = max(0, 3600 - len(prefix) - len(footer))
|
available = max(0, 3600 - len(prefix) - len(footer))
|
||||||
visible_sections = []
|
visible_sections = []
|
||||||
used = 0
|
used = 0
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ pnpm install
|
|||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
|
||||||
|
|
||||||
提交前建议运行:
|
提交前建议运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2613,6 +2613,25 @@ 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) {
|
func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
stopTestWorkers(a)
|
stopTestWorkers(a)
|
||||||
@@ -2756,8 +2775,20 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
|
|||||||
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, secondID).Scan(&recipientsJSON); err != nil {
|
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, secondID).Scan(&recipientsJSON); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") || strings.Contains(recipientsJSON, "account-forward@example.test") || strings.Contains(recipientsJSON, "account-forward-two@example.test") {
|
if !strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
|
||||||
t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON)
|
t.Fatalf("mailbox forwarding should include account and mailbox targets, recipients=%s", recipientsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := admin.do("POST", "/api/me/forwarding/account", map[string]any{"targetEmails": []string{"account-forward-two@example.test"}}, &settings); code != http.StatusOK {
|
||||||
|
t.Fatalf("update account forwarding after mailbox forwarding code=%d settings=%+v", code, settings)
|
||||||
|
}
|
||||||
|
raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: account changed\r\nMessage-ID: <account-changed@example.test>\r\n\r\nbody")
|
||||||
|
thirdID := insertInbound("<account-changed@example.test>", "account changed", raw)
|
||||||
|
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, thirdID).Scan(&recipientsJSON); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
|
||||||
|
t.Fatalf("changing account forwarding should preserve mailbox targets, recipients=%s", recipientsJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
loopRaw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: loop\r\n" + forwardingHeaderName + ": mail.example.test\r\nMessage-ID: <forward-loop@example.test>\r\n\r\nbody")
|
loopRaw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: loop\r\n" + forwardingHeaderName + ": mail.example.test\r\nMessage-ID: <forward-loop@example.test>\r\n\r\nbody")
|
||||||
@@ -2766,8 +2797,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
|
|||||||
if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil {
|
if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if queueCount != 2 {
|
if queueCount != 3 {
|
||||||
t.Fatalf("forwarding queue count=%d, want 2", queueCount)
|
t.Fatalf("forwarding queue count=%d, want 3", queueCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4480,6 +4511,49 @@ 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) {
|
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
host, port, received := startCapturingSMTP(t, 2)
|
host, port, received := startCapturingSMTP(t, 2)
|
||||||
@@ -4517,7 +4591,7 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := client.Auth(sasl.NewPlainClient("", "admin@lanqin.local", "ChangeMe123!")); err != nil {
|
if err := client.Auth(sasl.NewLoginClient("admin@lanqin.local", "ChangeMe123!")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
|
if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
|
||||||
|
|||||||
@@ -138,10 +138,7 @@ func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
|
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
|
||||||
if len(targets) == 0 {
|
|
||||||
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
|
|
||||||
}
|
|
||||||
if len(targets) == 0 {
|
if len(targets) == 0 {
|
||||||
return nil, userID, mailboxAddress, nil
|
return nil, userID, mailboxAddress, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request
|
|||||||
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
|
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
|
||||||
return
|
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) {
|
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -439,14 +439,18 @@ func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int
|
|||||||
title := "邮箱转发验证"
|
title := "邮箱转发验证"
|
||||||
heading := "验证失败"
|
heading := "验证失败"
|
||||||
color := "#dc2626"
|
color := "#dc2626"
|
||||||
|
statusMark := "!"
|
||||||
|
closingMessage := "请联系验证发起人重新发送链接"
|
||||||
if ok {
|
if ok {
|
||||||
heading = "验证完成"
|
heading = "验证完成"
|
||||||
color = "#2563eb"
|
color = "#16a34a"
|
||||||
|
statusMark = "✓"
|
||||||
|
closingMessage = "验证结果已记录,可以关闭此页面"
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
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: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>`,
|
_, _ = 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, heading, htmlEscape(message), htmlEscape(email), color)
|
title, color, statusMark, heading, htmlEscape(message), htmlEscape(email), htmlEscape(closingMessage))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
|
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
|
||||||
|
|||||||
@@ -121,21 +121,59 @@ type submissionSession struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *submissionSession) AuthMechanisms() []string {
|
func (s *submissionSession) AuthMechanisms() []string {
|
||||||
return []string{sasl.Plain}
|
return []string{sasl.Plain, sasl.Login}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
||||||
if !strings.EqualFold(mech, sasl.Plain) {
|
authenticate := func(username, password string) error {
|
||||||
return nil, smtpserver.ErrAuthUnknownMechanism
|
|
||||||
}
|
|
||||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
|
||||||
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
|
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return smtpserver.ErrAuthFailed
|
return smtpserver.ErrAuthFailed
|
||||||
}
|
}
|
||||||
s.user, s.mailbox = user, mailbox
|
s.user, s.mailbox = user, mailbox
|
||||||
return nil
|
return nil
|
||||||
}), 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 {
|
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.27.0",
|
"@tiptap/starter-kit": "^3.27.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"dompurify": "3.4.12",
|
"dompurify": "3.4.13",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Navigate, useLocation } from "react-router-dom"
|
import { Navigate, useLocation } from "react-router-dom"
|
||||||
import { useMe, isTimeoutError } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { AuthLoading, AuthError } from "@/components/auth-states"
|
import { AuthLoading, AuthError } from "@/components/auth-states"
|
||||||
|
import { isUnauthorizedError } from "@/lib/api"
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
if (me.isLoading) return <AuthLoading />
|
if (me.isLoading) return <AuthLoading />
|
||||||
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
if (me.isError && !isUnauthorizedError(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 }} />
|
if (me.isError || !me.data?.user) {
|
||||||
|
const from = `${location.pathname}${location.search}${location.hash}`
|
||||||
|
return <Navigate to="/login" replace state={{ from }} />
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export function AuthLoading() {
|
export function AuthLoading() {
|
||||||
return <div className="grid min-h-screen place-items-center text-muted-foreground">加载中...</div>
|
return <main className="grid min-h-screen place-items-center text-muted-foreground">加载中...</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
<main 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="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>
|
<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>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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-1 focus-visible:ring-ring 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-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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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-1 focus-visible:ring-ring 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-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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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-1 focus:ring-ring 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-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
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-1 focus-visible:ring-ring 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-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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
||||||
import { api } from "@/lib/api"
|
import { api, isUnauthorizedError } from "@/lib/api"
|
||||||
import type { User } from "@/lib/api"
|
import type { User } from "@/lib/api"
|
||||||
|
|
||||||
type MeResponse = { user: User }
|
type MeResponse = { user: User }
|
||||||
@@ -10,11 +10,7 @@ export function useMe(
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: api.me,
|
queryFn: api.me,
|
||||||
retry: 1,
|
retry: (failureCount, error) => !isUnauthorizedError(error) && failureCount < 1,
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTimeoutError(error: unknown): boolean {
|
|
||||||
return error instanceof Error && error.message.includes("请求超时")
|
|
||||||
}
|
|
||||||
|
|||||||
+19
-19
@@ -5,34 +5,34 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 0 0% 9%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 0 0% 9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 0 0% 9%;
|
||||||
--primary: 224 44% 12%;
|
--primary: 0 0% 12%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 0 0% 96.1%;
|
||||||
--secondary-foreground: 222.2 84% 4.9%;
|
--secondary-foreground: 0 0% 12%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 0 0% 96.1%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 0 0% 42%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 0 0% 95%;
|
||||||
--accent-foreground: 222.2 84% 4.9%;
|
--accent-foreground: 0 0% 12%;
|
||||||
--destructive: 358 88% 61%;
|
--destructive: 358 88% 61%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 0 0% 89.8%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 0 0% 82%;
|
||||||
--ring: 216 22% 42%;
|
--ring: 0 0% 32%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--app-sidebar-width: 18rem;
|
--app-sidebar-width: 18rem;
|
||||||
--sidebar-background: 0 0% 100%;
|
--sidebar-background: 0 0% 100%;
|
||||||
--sidebar-foreground: 222.2 84% 4.9%;
|
--sidebar-foreground: 0 0% 16%;
|
||||||
--sidebar-primary: 224 44% 12%;
|
--sidebar-primary: 0 0% 12%;
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
--sidebar-accent: 210 40% 96.1%;
|
--sidebar-accent: 0 0% 95%;
|
||||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
--sidebar-accent-foreground: 0 0% 12%;
|
||||||
--sidebar-border: 214.3 31.8% 91.4%;
|
--sidebar-border: 0 0% 89.8%;
|
||||||
--sidebar-ring: 215.4 16.3% 46.9%;
|
--sidebar-ring: 0 0% 42%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
|
|||||||
+22
-4
@@ -4,6 +4,17 @@ export * from "./api-types"
|
|||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
const MAIL_DELIVERY_TIMEOUT_MS = 60_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 = {
|
export type MailSearchParams = {
|
||||||
q?: string
|
q?: string
|
||||||
from?: string
|
from?: string
|
||||||
@@ -45,27 +56,34 @@ function appendMailSearchParams(params: URLSearchParams, search: MailSearchParam
|
|||||||
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||||
const { timeoutMs, ...requestInit } = init
|
const { timeoutMs, ...requestInit } = init
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs || REQUEST_TIMEOUT_MS)
|
let timedOut = false
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
controller.abort()
|
||||||
|
}, timeoutMs || REQUEST_TIMEOUT_MS)
|
||||||
const externalSignal = requestInit.signal
|
const externalSignal = requestInit.signal
|
||||||
|
const abortFromExternalSignal = () => controller.abort()
|
||||||
if (externalSignal) {
|
if (externalSignal) {
|
||||||
if (externalSignal.aborted) controller.abort()
|
if (externalSignal.aborted) controller.abort()
|
||||||
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true })
|
else externalSignal.addEventListener("abort", abortFromExternalSignal, { once: true })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
|
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = `${res.status} ${res.statusText}`
|
let message = `${res.status} ${res.statusText}`
|
||||||
try { const body = await res.json(); message = body.error || message } catch {}
|
try { const body = await res.json(); message = body.error || message } catch {}
|
||||||
throw new Error(message)
|
throw new ApiError(message, res.status)
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>
|
return res.json() as Promise<T>
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === "AbortError") {
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
throw new Error("请求超时,请检查后端服务是否正常")
|
throw new Error(timedOut ? "请求超时,请检查后端服务是否正常" : "请求已取消")
|
||||||
}
|
}
|
||||||
|
if (error instanceof TypeError) throw new Error("无法连接后端服务,请检查服务状态")
|
||||||
throw error instanceof Error ? error : new Error("网络请求失败")
|
throw error instanceof Error ? error : new Error("网络请求失败")
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
|
externalSignal?.removeEventListener("abort", abortFromExternalSignal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function safeReturnPath(value: unknown, fallback = "/"): string {
|
||||||
|
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return fallback
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -101,6 +101,14 @@ export function AdminPage() {
|
|||||||
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
|
||||||
const rawSection = params.get("section") as Section | null
|
const rawSection = params.get("section") as Section | null
|
||||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
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() {
|
async function refreshAdminPage() {
|
||||||
if (refreshing) return
|
if (refreshing) return
|
||||||
@@ -124,6 +132,8 @@ export function AdminPage() {
|
|||||||
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
<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} />
|
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||||
|
|
||||||
|
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
|
||||||
|
|
||||||
{section === "overview" && canOverview && (
|
{section === "overview" && canOverview && (
|
||||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
||||||
@@ -397,7 +407,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
|||||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
|
||||||
</div>
|
</div>
|
||||||
{(canUpdate || canDelete) && <DropdownMenu>
|
{(canUpdate || canDelete) && <DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配置</DropdownMenuItem>
|
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配置</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -903,7 +913,8 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
{messages.isLoading && <Empty text="加载中..." />}
|
{messages.isLoading && <Empty text="加载中..." />}
|
||||||
{!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />}
|
{messages.isError && <QueryFailure error={messages.error} onRetry={() => { void messages.refetch() }} compact />}
|
||||||
|
{!messages.isLoading && !messages.isError && items.length === 0 && <Empty text="暂无邮件" />}
|
||||||
{!messages.isLoading && messages.hasNextPage && (
|
{!messages.isLoading && messages.hasNextPage && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
|
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
|
||||||
@@ -1016,7 +1027,8 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
{audit.isLoading && <Empty text="加载中..." />}
|
{audit.isLoading && <Empty text="加载中..." />}
|
||||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送记录" />}
|
{audit.isError && <QueryFailure error={audit.error} onRetry={() => { void audit.refetch() }} compact />}
|
||||||
|
{!audit.isLoading && !audit.isError && items.length === 0 && <Empty text="暂无发送记录" />}
|
||||||
{!audit.isLoading && audit.hasNextPage && (
|
{!audit.isLoading && audit.hasNextPage && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
||||||
@@ -1330,7 +1342,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="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
|
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
|
||||||
<Button type="button" variant="ghost" size="icon" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
|
<Button type="button" variant="ghost" size="icon" aria-label="复制绑定码" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1429,7 +1441,8 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
|
{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 === "security" && <Card>
|
{settingsTab === "security" && <Card>
|
||||||
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
<CardHeader><CardTitle>安全设置</CardTitle></CardHeader>
|
||||||
@@ -1808,6 +1821,18 @@ 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 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 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 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"] }) }
|
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
|
||||||
|
|
||||||
@@ -1968,7 +1993,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!canUpdate && !canResetPassword && !onDelete) return null
|
if (!canUpdate && !canResetPassword && !onDelete) return null
|
||||||
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} />}</>
|
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} />}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
||||||
@@ -2012,12 +2037,12 @@ function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
|
|||||||
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
if (!canUpdate && !onDelete) return null
|
if (!canUpdate && !onDelete) return null
|
||||||
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} />}</>
|
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} />}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
||||||
if (!onToggle && !onDelete) return null
|
if (!onToggle && !onDelete) return null
|
||||||
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>
|
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>
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
@@ -2146,15 +2171,15 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
|||||||
const user = me.data?.user
|
const user = me.data?.user
|
||||||
const canCheckDNS = hasPermission(user, "admin.dns.check")
|
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 { 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(";") }) } })
|
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 }) })
|
||||||
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground">请选择域名</CardContent></Card>
|
||||||
const content = <>
|
const content = <>
|
||||||
<p className="mb-3 text-sm text-muted-foreground">以下为需要在域名 DNS 管理中添加的记录:</p>
|
<p className="mb-3 text-sm text-muted-foreground">以下为需要在域名 DNS 管理中添加的记录:</p>
|
||||||
<div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>
|
{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>}
|
||||||
{check.data && <>
|
{check.data && <>
|
||||||
<Separator className="my-4" />
|
<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="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]) => <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>
|
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <DNSCheckRow key={k} name={k} check={v} />)}</div>
|
||||||
</>}</>
|
</>}</>
|
||||||
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" />检测</Button> : null
|
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>
|
const header = <div className="flex items-center justify-between"><CardTitle>DNS 记录</CardTitle>{checkButton}</div>
|
||||||
@@ -2162,6 +2187,20 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
|
|||||||
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
|
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 {
|
function dnsDescription(record: DNSRecord): string {
|
||||||
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
|
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
|
||||||
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
|
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
|
||||||
@@ -2212,19 +2251,25 @@ function fieldNumber(form: FormData, name: string, fallback: number) {
|
|||||||
return Number.isFinite(n) && n > 0 ? n : fallback
|
return Number.isFinite(n) && n > 0 ? n : fallback
|
||||||
}
|
}
|
||||||
function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; className?: string }) {
|
function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; className?: string }) {
|
||||||
|
const id = React.useId()
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-14 items-center justify-between gap-4 ${className}`}>
|
<div className={`flex min-h-14 items-center justify-between gap-4 ${className}`}>
|
||||||
<Label className="text-base font-medium">{label}</Label>
|
<Label htmlFor={id} className="text-base font-medium">{label}</Label>
|
||||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||||
</div>
|
</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 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 MailboxLimitField({ defaultValue }: { defaultValue: number }) {
|
function MailboxLimitField({ defaultValue }: { defaultValue: number }) {
|
||||||
|
const id = React.useId()
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>邮箱数量上限</Label>
|
<Label htmlFor={id}>邮箱数量上限</Label>
|
||||||
<Input name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
<Input id={id} name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
||||||
<div className="text-xs text-muted-foreground">普通用户默认 9 个,填 0 表示不限制。</div>
|
<div className="text-xs text-muted-foreground">普通用户默认 9 个,填 0 表示不限制。</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -2236,5 +2281,8 @@ function mailboxLimitFromForm(form: FormData, fallback = defaultMailboxLimitOver
|
|||||||
function effectiveMailboxLimit(user: AdminUser) {
|
function effectiveMailboxLimit(user: AdminUser) {
|
||||||
return user.mailboxLimitOverride ?? user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride
|
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 }) { 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 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 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> }
|
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 * as React from "react"
|
||||||
import { Link, Navigate } from "react-router-dom"
|
import { Link, Navigate, useLocation } from "react-router-dom"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
|
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
@@ -10,9 +10,12 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { safeReturnPath } from "@/lib/navigation"
|
||||||
|
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
|
const location = useLocation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||||
@@ -33,9 +36,12 @@ export function LoginPage() {
|
|||||||
onError: (e) => toast({ title: "登录失败", description: e.message }),
|
onError: (e) => toast({ title: "登录失败", description: e.message }),
|
||||||
})
|
})
|
||||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||||
if (me.data?.user) return <Navigate to="/" replace />
|
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() }} />
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
<main 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="w-full max-w-[420px]">
|
||||||
<div className="mb-7 text-center">
|
<div className="mb-7 text-center">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||||
@@ -82,6 +88,6 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-18
@@ -155,7 +155,6 @@ export function MailPage() {
|
|||||||
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
|
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
|
||||||
const [exportingMail, setExportingMail] = React.useState(false)
|
const [exportingMail, setExportingMail] = React.useState(false)
|
||||||
const [importingMail, setImportingMail] = React.useState(false)
|
const [importingMail, setImportingMail] = React.useState(false)
|
||||||
const [, setLastAutoRefreshAt] = React.useState<Date | null>(null)
|
|
||||||
const [bulkPending, setBulkPending] = React.useState(false)
|
const [bulkPending, setBulkPending] = React.useState(false)
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
||||||
@@ -427,7 +426,7 @@ export function MailPage() {
|
|||||||
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
|
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 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: "已移动" }) } })
|
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 cancelScheduledSend = useMutation({
|
const cancelScheduledSend = useMutation({
|
||||||
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
|
||||||
onMutate: (item) => setCancelingScheduledId(item.id),
|
onMutate: (item) => setCancelingScheduledId(item.id),
|
||||||
@@ -646,7 +645,6 @@ export function MailPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
||||||
]).finally(() => {
|
]).finally(() => {
|
||||||
setLastAutoRefreshAt(new Date())
|
|
||||||
window.setTimeout(() => setAutoRefreshing(false), 600)
|
window.setTimeout(() => setAutoRefreshing(false), 600)
|
||||||
})
|
})
|
||||||
}, mailRefreshInterval || 30000)
|
}, mailRefreshInterval || 30000)
|
||||||
@@ -700,10 +698,12 @@ export function MailPage() {
|
|||||||
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
|
||||||
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
|
||||||
const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading
|
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 mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage
|
||||||
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage
|
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage
|
||||||
const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore
|
const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore
|
||||||
const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage()
|
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) {
|
function toggleCompactSelectAll(checked: boolean) {
|
||||||
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
setCompactSelectedIds(checked ? visibleMessageIds : [])
|
||||||
}
|
}
|
||||||
@@ -1103,7 +1103,6 @@ export function MailPage() {
|
|||||||
try {
|
try {
|
||||||
await refreshMailData()
|
await refreshMailData()
|
||||||
const refreshedAt = new Date()
|
const refreshedAt = new Date()
|
||||||
setLastAutoRefreshAt(refreshedAt)
|
|
||||||
toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` })
|
toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||||
@@ -1263,6 +1262,7 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="px-1">
|
<SidebarContent className="px-1">
|
||||||
|
{!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />}
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
@@ -1316,8 +1316,10 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{folders.isLoading && <FolderSkeleton />}
|
{folders.isLoading && <FolderSkeleton />}
|
||||||
|
{!sidebarCollapsed && folders.isError && <SidebarQueryFailure label="文件夹读取失败" onRetry={() => { void folders.refetch() }} />}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
{!sidebarCollapsed && externalImapEnabled && externalMailAccounts.isError && <SidebarQueryFailure label="外部邮箱读取失败" onRetry={() => { void externalMailAccounts.refetch() }} />}
|
||||||
{externalAccountItems.length > 0 && <SidebarGroup>
|
{externalAccountItems.length > 0 && <SidebarGroup>
|
||||||
{!sidebarCollapsed && <SidebarGroupLabel>外部邮箱</SidebarGroupLabel>}
|
{!sidebarCollapsed && <SidebarGroupLabel>外部邮箱</SidebarGroupLabel>}
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -1360,6 +1362,7 @@ export function MailPage() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
{!sidebarCollapsed && expanded && externalFolders.isLoading && <FolderSkeleton />}
|
{!sidebarCollapsed && expanded && externalFolders.isLoading && <FolderSkeleton />}
|
||||||
|
{!sidebarCollapsed && expanded && account.id === selectedExternalAccountId && externalFolders.isError && <SidebarQueryFailure label="远端文件夹读取失败" onRetry={() => { void externalFolders.refetch() }} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)})}
|
)})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -1370,7 +1373,7 @@ export function MailPage() {
|
|||||||
<div className="flex items-center justify-between px-2 py-1">
|
<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>
|
<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 && (
|
{canManageFolders && (
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)}>
|
<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={() => setFolderDialogOpen(true)}>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1430,11 +1433,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>
|
<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 && (
|
{canManageCurrentMailboxLabels && (
|
||||||
<div className="flex items-center gap-0.5">
|
<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" 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" aria-label="新建标签" title="新建标签" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{labelEditMode && (
|
{labelEditMode && (
|
||||||
<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)}>
|
<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)}>
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1479,7 +1482,7 @@ export function MailPage() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{canReadMail && !sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground">暂无标签</div>}
|
{canReadMail && !sidebarCollapsed && !labels.isLoading && !labels.isError && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground">暂无标签</div>}
|
||||||
{canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && (
|
{canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && (
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} />
|
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} />
|
||||||
@@ -1487,6 +1490,7 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{labels.isLoading && <FolderSkeleton />}
|
{labels.isLoading && <FolderSkeleton />}
|
||||||
|
{!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>}
|
</SidebarGroup>}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
@@ -1513,8 +1517,12 @@ export function MailPage() {
|
|||||||
<PermissionEmptyState title="无邮箱前台权限" description="当前账号未开启邮箱前台访问权限。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无邮箱前台权限" description="当前账号未开启邮箱前台访问权限。" onOpenSettings={openSettings} />
|
||||||
) : !canReadMail ? (
|
) : !canReadMail ? (
|
||||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||||
|
) : mailboxList.isError ? (
|
||||||
|
<MailQueryFailure error={mailboxList.error} onRetry={() => { void mailboxList.refetch() }} />
|
||||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||||
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
|
||||||
|
) : mailView === "scheduled" && scheduledSends.isError ? (
|
||||||
|
<MailQueryFailure error={scheduledSends.error} onRetry={() => { void scheduledSends.refetch() }} />
|
||||||
) : mailView === "scheduled" && canScheduleMail ? (
|
) : mailView === "scheduled" && canScheduleMail ? (
|
||||||
<ScheduledSendView
|
<ScheduledSendView
|
||||||
compact={compactMailLayout}
|
compact={compactMailLayout}
|
||||||
@@ -1527,6 +1535,8 @@ export function MailPage() {
|
|||||||
/>
|
/>
|
||||||
) : mailView === "scheduled" ? (
|
) : mailView === "scheduled" ? (
|
||||||
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
||||||
|
) : mailView === "sendQueue" && sendQueue.isError ? (
|
||||||
|
<MailQueryFailure error={sendQueue.error} onRetry={() => { void sendQueue.refetch() }} />
|
||||||
) : mailView === "sendQueue" && canViewSendQueue ? (
|
) : mailView === "sendQueue" && canViewSendQueue ? (
|
||||||
<SendQueueView
|
<SendQueueView
|
||||||
compact={compactMailLayout}
|
compact={compactMailLayout}
|
||||||
@@ -1552,6 +1562,8 @@ export function MailPage() {
|
|||||||
/>
|
/>
|
||||||
) : mailView === "sendQueue" ? (
|
) : mailView === "sendQueue" ? (
|
||||||
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
|
||||||
|
) : mailMessagesError ? (
|
||||||
|
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
|
||||||
) : compactMailLayout ? (
|
) : compactMailLayout ? (
|
||||||
<CompactMailView
|
<CompactMailView
|
||||||
title={viewTitle}
|
title={viewTitle}
|
||||||
@@ -1569,6 +1581,8 @@ export function MailPage() {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
detailLoading={detail.isLoading}
|
detailLoading={detail.isLoading}
|
||||||
|
detailError={detail.error}
|
||||||
|
onRetryDetail={() => { void detail.refetch() }}
|
||||||
labels={labelItems}
|
labels={labelItems}
|
||||||
labelPending={addLabel.isPending || removeLabel.isPending}
|
labelPending={addLabel.isPending || removeLabel.isPending}
|
||||||
onSelect={openMessage}
|
onSelect={openMessage}
|
||||||
@@ -1690,7 +1704,8 @@ export function MailPage() {
|
|||||||
</div>
|
</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.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>}
|
||||||
{selected && <div className="flex h-full min-h-0 flex-col">
|
{detail.isError && <MailDetailFailure error={detail.error} onRetry={() => { void detail.refetch() }} />}
|
||||||
|
{!detail.isError && selected && <div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="border-b p-5">
|
<div className="border-b p-5">
|
||||||
{isTwoPaneMailViewport && (
|
{isTwoPaneMailViewport && (
|
||||||
<Button variant="ghost" size="sm" className="-ml-2 mb-3 h-8 px-2 text-[13px] font-normal" onClick={() => setSelectedId(null)}>
|
<Button variant="ghost" size="sm" className="-ml-2 mb-3 h-8 px-2 text-[13px] font-normal" onClick={() => setSelectedId(null)}>
|
||||||
@@ -1773,7 +1788,9 @@ export function MailPage() {
|
|||||||
<SendQueueAuditDialog
|
<SendQueueAuditDialog
|
||||||
open={!!sendQueueAuditId}
|
open={!!sendQueueAuditId}
|
||||||
loading={sendQueueAudit.isLoading}
|
loading={sendQueueAudit.isLoading}
|
||||||
|
error={sendQueueAudit.error}
|
||||||
events={sendQueueAudit.data?.items || []}
|
events={sendQueueAudit.data?.items || []}
|
||||||
|
onRetry={() => { void sendQueueAudit.refetch() }}
|
||||||
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
|
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
|
||||||
/>
|
/>
|
||||||
<MessageContextMenu
|
<MessageContextMenu
|
||||||
@@ -2143,6 +2160,45 @@ 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 }) {
|
function ScheduledSendView({ compact, items, total, loading, query, cancelingId, onCancel }: { compact: boolean; items: ScheduledSend[]; total: number; loading: boolean; query: string; cancelingId: string; onCancel: (item: ScheduledSend) => void }) {
|
||||||
const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
|
const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
|
||||||
return (
|
return (
|
||||||
@@ -2378,7 +2434,7 @@ function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) {
|
function SendQueueAuditDialog({ open, loading, error, events, onRetry, onOpenChange }: { open: boolean; loading: boolean; error: Error | null; events: SendQueueAuditEvent[]; onRetry: () => void; onOpenChange: (open: boolean) => void }) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
|
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
|
||||||
@@ -2387,8 +2443,9 @@ function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: b
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[60vh] overflow-auto pr-1">
|
<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 && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
|
||||||
{!loading && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">暂无投递事件</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 && events.length > 0 && (
|
{!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 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div key={event.id} className="rounded-lg border p-3">
|
<div key={event.id} className="rounded-lg border p-3">
|
||||||
@@ -2709,6 +2766,7 @@ function CompactMailView({
|
|||||||
selectedId,
|
selectedId,
|
||||||
selected,
|
selected,
|
||||||
detailLoading,
|
detailLoading,
|
||||||
|
detailError,
|
||||||
labels,
|
labels,
|
||||||
labelPending,
|
labelPending,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -2716,6 +2774,7 @@ function CompactMailView({
|
|||||||
onToggleSelected,
|
onToggleSelected,
|
||||||
scheduledDraftIds,
|
scheduledDraftIds,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
|
onRetryDetail,
|
||||||
onCloseReader,
|
onCloseReader,
|
||||||
onStar,
|
onStar,
|
||||||
onReply,
|
onReply,
|
||||||
@@ -2750,6 +2809,7 @@ function CompactMailView({
|
|||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
selected?: MailMessage
|
selected?: MailMessage
|
||||||
detailLoading: boolean
|
detailLoading: boolean
|
||||||
|
detailError: Error | null
|
||||||
labels: MailLabel[]
|
labels: MailLabel[]
|
||||||
labelPending: boolean
|
labelPending: boolean
|
||||||
onSelect: (id: string | null) => void
|
onSelect: (id: string | null) => void
|
||||||
@@ -2757,6 +2817,7 @@ function CompactMailView({
|
|||||||
onToggleSelected: (id: string, checked: boolean) => void
|
onToggleSelected: (id: string, checked: boolean) => void
|
||||||
scheduledDraftIds: Set<string>
|
scheduledDraftIds: Set<string>
|
||||||
onLoadMore: () => void
|
onLoadMore: () => void
|
||||||
|
onRetryDetail: () => void
|
||||||
onCloseReader: () => void
|
onCloseReader: () => void
|
||||||
onStar: (message: MailMessage) => void
|
onStar: (message: MailMessage) => void
|
||||||
onReply: (message: MailMessage) => void
|
onReply: (message: MailMessage) => void
|
||||||
@@ -2786,11 +2847,13 @@ function CompactMailView({
|
|||||||
<CompactMessageDetail
|
<CompactMessageDetail
|
||||||
selected={selected}
|
selected={selected}
|
||||||
loading={detailLoading}
|
loading={detailLoading}
|
||||||
|
error={detailError}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
labelPending={labelPending}
|
labelPending={labelPending}
|
||||||
previousMessage={previousMessage}
|
previousMessage={previousMessage}
|
||||||
nextMessage={nextMessage}
|
nextMessage={nextMessage}
|
||||||
onBack={onCloseReader}
|
onBack={onCloseReader}
|
||||||
|
onRetry={onRetryDetail}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onStar={onStar}
|
onStar={onStar}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
@@ -2853,11 +2916,13 @@ function CompactMailView({
|
|||||||
function CompactMessageDetail({
|
function CompactMessageDetail({
|
||||||
selected,
|
selected,
|
||||||
loading,
|
loading,
|
||||||
|
error,
|
||||||
labels,
|
labels,
|
||||||
labelPending,
|
labelPending,
|
||||||
previousMessage,
|
previousMessage,
|
||||||
nextMessage,
|
nextMessage,
|
||||||
onBack,
|
onBack,
|
||||||
|
onRetry,
|
||||||
onSelect,
|
onSelect,
|
||||||
onStar,
|
onStar,
|
||||||
onReply,
|
onReply,
|
||||||
@@ -2876,11 +2941,13 @@ function CompactMessageDetail({
|
|||||||
}: {
|
}: {
|
||||||
selected?: MailMessage
|
selected?: MailMessage
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
labels: MailLabel[]
|
labels: MailLabel[]
|
||||||
labelPending: boolean
|
labelPending: boolean
|
||||||
previousMessage?: MailMessage
|
previousMessage?: MailMessage
|
||||||
nextMessage?: MailMessage
|
nextMessage?: MailMessage
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
onRetry: () => void
|
||||||
onSelect: (id: string | null) => void
|
onSelect: (id: string | null) => void
|
||||||
onStar: (message: MailMessage) => void
|
onStar: (message: MailMessage) => void
|
||||||
onReply: (message: MailMessage) => void
|
onReply: (message: MailMessage) => void
|
||||||
@@ -2960,8 +3027,9 @@ function CompactMessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
</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 && <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 && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground">邮件不存在</div>}
|
{!loading && error && <MailDetailFailure error={error} onRetry={onRetry} />}
|
||||||
{selected && (
|
{!loading && !error && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground">邮件不存在</div>}
|
||||||
|
{!error && selected && (
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="w-full px-4 py-4 sm:px-8 sm:py-6">
|
<div className="w-full px-4 py-4 sm:px-8 sm:py-6">
|
||||||
<div className="space-y-5 border-b pb-5">
|
<div className="space-y-5 border-b pb-5">
|
||||||
@@ -3682,12 +3750,15 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
return sent
|
return sent
|
||||||
},
|
},
|
||||||
onSuccess: async (_, payloads) => {
|
onSuccess: async (_, payloads) => {
|
||||||
|
let draftDeleteFailed = false
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
try {
|
try {
|
||||||
await api.deleteDraft(draftId)
|
await api.deleteDraft(draftId)
|
||||||
} catch {}
|
} catch {
|
||||||
|
draftDeleteFailed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功" })
|
toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功", description: draftDeleteFailed ? "邮件已发送,但原草稿未能自动删除" : undefined })
|
||||||
setFiles([])
|
setFiles([])
|
||||||
setDraftId("")
|
setDraftId("")
|
||||||
onSent()
|
onSent()
|
||||||
@@ -3952,6 +4023,14 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
|
|||||||
<ComposeField label="主 题">
|
<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" />
|
<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>
|
</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
|
<MailBodyComposer
|
||||||
defaultValue={composerText}
|
defaultValue={composerText}
|
||||||
defaultHtml={draft?.html}
|
defaultHtml={draft?.html}
|
||||||
@@ -4383,7 +4462,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
<DropdownMenuContent align="start" className="w-64 p-2">
|
<DropdownMenuContent align="start" className="w-64 p-2">
|
||||||
<div className="grid grid-cols-8 gap-1">
|
<div className="grid grid-cols-8 gap-1">
|
||||||
{composerEmojiOptions.map((emoji) => (
|
{composerEmojiOptions.map((emoji) => (
|
||||||
<Button key={emoji} type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-lg" onClick={() => insertEmoji(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)}>
|
||||||
{emoji}
|
{emoji}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -4495,7 +4574,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
|
|||||||
<Paperclip className="h-3.5 w-3.5" />
|
<Paperclip className="h-3.5 w-3.5" />
|
||||||
<span className="max-w-48 truncate">{file.name}</span>
|
<span className="max-w-48 truncate">{file.name}</span>
|
||||||
<span className="text-muted-foreground">{formatBytes(file.size)}</span>
|
<span className="text-muted-foreground">{formatBytes(file.size)}</span>
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 rounded-md" onClick={() => onRemoveFile(index)}>
|
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 rounded-md" aria-label={`移除附件 ${file.name}`} title="移除附件" onClick={() => onRemoveFile(index)}>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Home, MailQuestion } from "lucide-react"
|
|||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||||
<div className="w-full max-w-sm text-center">
|
<div className="w-full max-w-sm text-center">
|
||||||
<div className="mb-6 flex justify-center">
|
<div className="mb-6 flex justify-center">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||||
@@ -24,6 +24,6 @@ export function NotFoundPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+292
-131
@@ -32,6 +32,7 @@ import { useToast } from "@/hooks/use-toast"
|
|||||||
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
|
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
|
||||||
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
|
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
|
||||||
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
|
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 }> = {
|
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
||||||
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
|
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
|
||||||
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
|
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
|
||||||
@@ -50,6 +51,8 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
|
|||||||
{ key: "clients", label: "通知与客户端" },
|
{ key: "clients", label: "通知与客户端" },
|
||||||
{ key: "security", label: "安全" },
|
{ key: "security", label: "安全" },
|
||||||
]
|
]
|
||||||
|
const forwardingTargetCollator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -176,7 +179,7 @@ export function ProfilePage() {
|
|||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
|
||||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||||
})
|
})
|
||||||
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
|
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const createSignature = useMutation({
|
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" }),
|
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: "签名已保存" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已保存" }) },
|
||||||
@@ -192,7 +195,7 @@ export function ProfilePage() {
|
|||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) },
|
||||||
onError: (error) => toast({ title: "设置失败", description: error.message }),
|
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 deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const createRule = useMutation({
|
const createRule = useMutation({
|
||||||
mutationFn: (payload: {
|
mutationFn: (payload: {
|
||||||
mailboxId: string
|
mailboxId: string
|
||||||
@@ -214,7 +217,7 @@ export function ProfilePage() {
|
|||||||
},
|
},
|
||||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||||
})
|
})
|
||||||
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
|
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const updateRule = useMutation({
|
const updateRule = useMutation({
|
||||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<RuleCreatePayload> }) => api.updateRule(id, payload),
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<RuleCreatePayload> }) => api.updateRule(id, payload),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) },
|
||||||
@@ -241,7 +244,7 @@ export function ProfilePage() {
|
|||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) },
|
||||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||||
})
|
})
|
||||||
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) } })
|
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
|
||||||
const createLabel = useMutation({
|
const createLabel = useMutation({
|
||||||
mutationFn: (form: FormData) => api.createLabel({ mailboxId: activeMailboxId, name: String(form.get("name") || ""), color: String(form.get("color") || "") }),
|
mutationFn: (form: FormData) => api.createLabel({ mailboxId: activeMailboxId, name: String(form.get("name") || ""), color: String(form.get("color") || "") }),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已创建" }) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已创建" }) },
|
||||||
@@ -431,6 +434,15 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
function renderTab() {
|
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 (
|
if (tab === "profile") return (
|
||||||
<AccountSettingsSection
|
<AccountSettingsSection
|
||||||
activeTab={accountTab}
|
activeTab={accountTab}
|
||||||
@@ -504,6 +516,22 @@ 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} />
|
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
|
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) {
|
function contentFrameClass(tab: Tab) {
|
||||||
@@ -675,7 +703,20 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
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 }) {
|
||||||
const accountName = user.email
|
const accountName = user.email
|
||||||
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
|
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
|
||||||
const storageBytes = stats?.storageBytes || 0
|
const storageBytes = stats?.storageBytes || 0
|
||||||
@@ -683,26 +724,35 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SettingsCard title="账号信息">
|
<SettingsCard title="账号信息">
|
||||||
<div className="space-y-5">
|
<form className="space-y-5" onSubmit={(event) => { event.preventDefault(); profile.mutate(new FormData(event.currentTarget)) }}>
|
||||||
<InfoLine label="主登录邮箱" value={accountName} />
|
<InfoLine label="主登录邮箱" value={accountName} />
|
||||||
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||||
<Label className="text-base font-normal text-muted-foreground">时区</Label>
|
<Label htmlFor="profile-display-name" className="text-base font-normal text-muted-foreground">显示名称</Label>
|
||||||
<Select defaultValue="Asia/Shanghai">
|
<div className="flex flex-col gap-2 sm:ml-auto sm:w-[320px] sm:flex-row">
|
||||||
<SelectTrigger className="h-[29px] sm:ml-auto sm:w-[236px]">
|
<Input id="profile-display-name" name="displayName" defaultValue={user.displayName} maxLength={80} required className="h-9" />
|
||||||
<SelectValue />
|
<Button type="submit" size="sm" disabled={profile.isPending}>{profile.isPending ? "保存中..." : "保存"}</Button>
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsCard title="存储容量" action={<Button type="button" variant="outline" size="sm" onClick={onOpenCleanup}>邮件清理</Button>}>
|
{showStats && <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">
|
<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 ? `${formatBytes(storageBytes)} / ${formatBytes(quotaBytes)}` : formatBytes(storageBytes)}</span>
|
||||||
<span>{quotaBytes > 0 ? `${quotaPct}%` : "不限"}</span>
|
<span>{quotaBytes > 0 ? `${quotaPct}%` : "不限"}</span>
|
||||||
@@ -710,7 +760,7 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
|
|||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
<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 className="h-full rounded-full bg-primary transition-all" style={{ width: `${quotaBytes > 0 ? quotaPct : 12}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>}
|
||||||
|
|
||||||
<SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground">实时按当前账号配置计算</span>}>
|
<SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground">实时按当前账号配置计算</span>}>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
@@ -860,8 +910,8 @@ function MailPreferencesSection({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
{!item.isDefault && <Button type="button" variant="outline" size="sm" disabled={signaturesPending} onClick={() => onSetDefaultSignature(item.id)}>设为默认</Button>}
|
{!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" onClick={() => setEditingSignature(item)}><PencilLine className="h-4 w-4" /></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" 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 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -921,7 +971,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 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>
|
||||||
<div className="font-semibold">{user.email}</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>
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
@@ -997,27 +1047,10 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
|||||||
)}
|
)}
|
||||||
</SettingsCard>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedRequestRow(email: string, status: string, duration: string) {
|
|
||||||
return { email, status, duration }
|
|
||||||
}
|
|
||||||
|
|
||||||
function CleanupQueueSection({ mailbox, stats }: { mailbox?: Mailbox; stats?: MailStats }) {
|
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 }))
|
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 (
|
return (
|
||||||
@@ -1093,13 +1126,25 @@ function MailboxManagement({
|
|||||||
const [mailboxSearch, setMailboxSearch] = React.useState("")
|
const [mailboxSearch, setMailboxSearch] = React.useState("")
|
||||||
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
|
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
|
||||||
const [forwardDraft, setForwardDraft] = React.useState<string[]>([])
|
const [forwardDraft, setForwardDraft] = React.useState<string[]>([])
|
||||||
|
const [forwardingTargetPreview, setForwardingTargetPreview] = React.useState<{ title: string; subtitle: string; source: string; targets: string[] } | null>(null)
|
||||||
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
|
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
|
||||||
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
|
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
|
||||||
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
|
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
|
||||||
|
const [verifiedEmailsExpanded, setVerifiedEmailsExpanded] = React.useState(false)
|
||||||
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
|
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
|
||||||
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
|
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
|
||||||
const verifiedEmailItems = forwarding.data?.verifiedEmails || []
|
const verifiedEmailItems = React.useMemo(() => [...(forwarding.data?.verifiedEmails || [])].sort((a, b) => forwardingTargetCollator.compare(a.email, b.email)), [forwarding.data?.verifiedEmails])
|
||||||
const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems])
|
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 hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified)
|
||||||
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
|
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
|
||||||
const next: Record<string, string[]> = {}
|
const next: Record<string, string[]> = {}
|
||||||
@@ -1113,9 +1158,10 @@ function MailboxManagement({
|
|||||||
const domainOptions = applyOptions?.domains || []
|
const domainOptions = applyOptions?.domains || []
|
||||||
const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0]
|
const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0]
|
||||||
const selectedMailbox = mailboxes.find((mailbox) => mailbox.id === selectedMailboxId) || mailboxes[0]
|
const selectedMailbox = mailboxes.find((mailbox) => mailbox.id === selectedMailboxId) || mailboxes[0]
|
||||||
|
const sortedMailboxes = React.useMemo(() => [...mailboxes].sort((a, b) => forwardingTargetCollator.compare(a.address, b.address)), [mailboxes])
|
||||||
const filteredMailboxes = normalizedMailboxSearch
|
const filteredMailboxes = normalizedMailboxSearch
|
||||||
? mailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
|
? sortedMailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
|
||||||
: mailboxes
|
: sortedMailboxes
|
||||||
const setForwardingCache = React.useCallback((settings: ForwardingSettings) => {
|
const setForwardingCache = React.useCallback((settings: ForwardingSettings) => {
|
||||||
qc.setQueryData(["forwarding-settings"], settings)
|
qc.setQueryData(["forwarding-settings"], settings)
|
||||||
}, [qc])
|
}, [qc])
|
||||||
@@ -1203,13 +1249,14 @@ function MailboxManagement({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openMailboxForward(mailbox: Mailbox) {
|
function openMailboxForward(mailbox: Mailbox) {
|
||||||
|
const mailboxTargets = mailboxForwards[mailbox.id]
|
||||||
setForwardingMailbox(mailbox)
|
setForwardingMailbox(mailbox)
|
||||||
setForwardDraft(mailboxForwards[mailbox.id] || [])
|
setForwardDraft(withoutForwardingTargets(mailboxTargets || [], accountForwardTargets))
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMailboxForward() {
|
function saveMailboxForward() {
|
||||||
if (!forwardingMailbox) return
|
if (!forwardingMailbox) return
|
||||||
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: forwardDraft })
|
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: withoutForwardingTargets(forwardDraft, accountForwardTargets) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) {
|
function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) {
|
||||||
@@ -1275,9 +1322,11 @@ function MailboxManagement({
|
|||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{filteredMailboxes.map((mailbox) => {
|
{filteredMailboxes.map((mailbox) => {
|
||||||
const forwardTargets = mailboxForwards[mailbox.id] || []
|
const forwardTargets = mailboxForwards[mailbox.id] || []
|
||||||
const accountForwardTargetActive = forwardTargets.length === 0 && accountForwardTargets.length > 0
|
const effectiveForwardTargets = mergeForwardingTargets(accountForwardTargets, forwardTargets)
|
||||||
const effectiveForwardTargets = forwardTargets.length > 0 ? forwardTargets : accountForwardTargetActive ? accountForwardTargets : []
|
|
||||||
const forwardingActive = effectiveForwardTargets.length > 0
|
const forwardingActive = effectiveForwardTargets.length > 0
|
||||||
|
const forwardingSource = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "账号级 + 邮箱单独" : accountForwardTargets.length > 0 ? "账号级" : "邮箱单独"
|
||||||
|
const forwardingSubtitle = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "账号级目标固定生效,并追加邮箱单独目标" : accountForwardTargets.length > 0 ? "继承账号级转发" : "邮箱单独转发"
|
||||||
|
const forwardingPrefix = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "转发:账号级+单独" : accountForwardTargets.length > 0 ? "转发:使用账号级" : "转发:"
|
||||||
return (
|
return (
|
||||||
<div key={mailbox.id} className={cn("grid gap-3 px-6 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center", selectedMailboxId === mailbox.id && "bg-muted/50")}>
|
<div key={mailbox.id} className={cn("grid gap-3 px-6 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center", selectedMailboxId === mailbox.id && "bg-muted/50")}>
|
||||||
<div className="flex min-w-0 items-center gap-4">
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
@@ -1291,8 +1340,13 @@ function MailboxManagement({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
<span>创建于 {formatDateTime(mailbox.createdAt)}</span>
|
<span>创建于 {formatDateTime(mailbox.createdAt)}</span>
|
||||||
{forwardTargets.length > 0 && <span className="max-w-full truncate font-semibold text-foreground">转发:{forwardTargets.join("、")}</span>}
|
{effectiveForwardTargets.length > 0 && (
|
||||||
{accountForwardTargetActive && <span className="max-w-full truncate font-semibold text-foreground">转发:使用账号级 {accountForwardTargets.join("、")}</span>}
|
<ForwardingTargetSummary
|
||||||
|
prefix={forwardingPrefix}
|
||||||
|
targets={effectiveForwardTargets}
|
||||||
|
onView={() => setForwardingTargetPreview({ title: mailbox.address, subtitle: forwardingSubtitle, source: forwardingSource, targets: effectiveForwardTargets })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1307,33 +1361,26 @@ function MailboxManagement({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="mailbox-forwarding-section" className="rounded-lg border bg-card px-6 py-5">
|
<section id="mailbox-forwarding-section" className="rounded-lg border bg-card px-6 py-5">
|
||||||
<div className="mb-5 flex items-center justify-between gap-4">
|
<div className="mb-5 flex items-center gap-4">
|
||||||
<h2 className="text-lg font-semibold leading-7">邮件转发</h2>
|
<h2 className="text-lg font-semibold leading-7">邮件转发</h2>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setVerifiedDialogOpen(true)}>管理验证邮箱</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-muted/20 px-5 py-5">
|
<div className="rounded-xl bg-muted/20 px-5 py-5">
|
||||||
<div className="mb-3 text-sm font-medium">账号级转发</div>
|
<div className="mb-3 text-sm font-medium">账号级转发</div>
|
||||||
<div className="mb-4 text-sm text-muted-foreground">对所有邮箱生效,可同时转发到多个已验证邮箱;邮箱单独设置优先级更高</div>
|
<div className="mb-4 text-sm text-muted-foreground">对所有邮箱生效,可同时转发到多个已验证邮箱;单个邮箱可继续追加自己的转发目标</div>
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
|
||||||
<ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
|
<ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
|
||||||
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
|
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{verifiedEmailItems.length > 0 && (
|
<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)}>
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
<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>
|
||||||
{verifiedEmailItems.map((item) => {
|
<span className="min-w-0 flex-1">
|
||||||
const tone = forwardingEmailTone(item)
|
<span className="block text-sm font-medium">管理验证邮箱</span>
|
||||||
return (
|
<span className="block truncate text-sm text-muted-foreground">已验证 {completedVerifiedEmailItems.length} 个{pendingVerifiedEmailItems.length > 0 ? `,待验证 ${pendingVerifiedEmailItems.length} 个` : ""}</span>
|
||||||
<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>
|
||||||
<span className={cn("size-2 shrink-0 rounded-full", tone.dotClass)} />
|
<ChevronDown className="h-4 w-4 shrink-0 -rotate-90 text-muted-foreground" />
|
||||||
<span className="min-w-0 truncate">{item.email} · {tone.shortLabel}</span>
|
</Button>
|
||||||
</span>
|
<p className="mt-3 text-sm text-muted-foreground">提示:点击邮箱列表中的「转发」按钮,可在账号级目标之外追加该邮箱自己的转发目标。</p>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
{externalImapEnabled && (
|
{externalImapEnabled && (
|
||||||
@@ -1390,55 +1437,94 @@ function MailboxManagement({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">转发到</Label>
|
<Label className="text-sm font-medium">转发到</Label>
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_64px_64px] items-start gap-2">
|
<div className="grid grid-cols-[minmax(0,1fr)_64px_64px] items-start gap-2">
|
||||||
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} onChange={setForwardDraft} disabled={forwardingBusy} />
|
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} lockedSelected={accountForwardTargets} lockedLabel="账号级" onChange={(targets) => setForwardDraft(withoutForwardingTargets(targets, accountForwardTargets))} disabled={forwardingBusy} />
|
||||||
<Button type="button" variant="outline" className="h-[37px] px-0" disabled={forwardingBusy} onClick={() => setForwardingMailbox(null)}>取消</Button>
|
<Button type="button" variant="outline" className="h-[37px] px-0" disabled={forwardingBusy} onClick={() => setForwardingMailbox(null)}>取消</Button>
|
||||||
<Button type="button" className="h-[37px] px-0" disabled={forwardingBusy} onClick={saveMailboxForward}>{saveMailboxForwarding.isPending ? "保存中" : "保存"}</Button>
|
<Button type="button" className="h-[37px] px-0" disabled={forwardingBusy} onClick={saveMailboxForward}>{saveMailboxForwarding.isPending ? "保存中" : "保存"}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{accountForwardTargets.length > 0 && <p className="text-xs text-muted-foreground">账号级目标已置顶锁定,不能在单个邮箱里取消;下方可追加邮箱单独目标。</p>}
|
||||||
</div>
|
</div>
|
||||||
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground">暂未添加验证邮箱,请先点击「管理验证邮箱」添加。</p>}
|
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground">暂未添加验证邮箱,请先点击「管理验证邮箱」添加。</p>}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
|
<Dialog open={!!forwardingTargetPreview} onOpenChange={(open) => { if (!open) setForwardingTargetPreview(null) }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader><DialogTitle>转发目标</DialogTitle></DialogHeader>
|
||||||
|
{forwardingTargetPreview && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm text-muted-foreground">{forwardingTargetPreview.title}</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{forwardingTargetPreview.source}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">{forwardingTargetPreview.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[320px] overflow-y-auto rounded-lg border">
|
||||||
|
{forwardingTargetPreview.targets.map((email) => (
|
||||||
|
<div key={email} className="flex min-h-11 items-center gap-3 border-b px-3 py-2 text-sm last:border-b-0">
|
||||||
|
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
|
||||||
|
<span className="min-w-0 break-all font-medium">{email}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={verifiedDialogOpen} onOpenChange={(open) => {
|
||||||
|
setVerifiedDialogOpen(open)
|
||||||
|
if (!open) setVerifiedEmailDraft("")
|
||||||
|
}}>
|
||||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
|
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
|
||||||
<DialogHeader className="px-8 pt-8">
|
<DialogHeader className="px-8 pt-8">
|
||||||
<DialogTitle className="text-2xl leading-8">验证邮箱管理</DialogTitle>
|
<DialogTitle className="text-2xl leading-8">验证邮箱管理</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="px-8 pt-5 text-[17px] leading-8 text-muted-foreground">
|
<div className="px-8 pt-4 text-sm leading-6 text-muted-foreground">
|
||||||
添加并验证外部邮箱地址后,才能用作转发目标。这里只展示投递状态摘要,不展示验证邮件内容。
|
搜索已添加地址,或输入新的外部邮箱并发送验证邮件。
|
||||||
</div>
|
</div>
|
||||||
<form className="grid gap-3 px-8 pt-6 sm:grid-cols-[minmax(0,1fr)_96px]" onSubmit={submitVerifiedEmail}>
|
<form className="grid gap-3 px-8 pt-6 sm:grid-cols-[minmax(0,1fr)_96px]" onSubmit={submitVerifiedEmail}>
|
||||||
<Input type="email" value={verifiedEmailDraft} onChange={(event) => setVerifiedEmailDraft(event.target.value)} className="h-12 text-base shadow-none" placeholder="输入邮箱地址" disabled={forwardingBusy} />
|
<div className="relative">
|
||||||
<Button className="h-12 px-0 text-base" disabled={forwardingBusy || !verifiedEmailDraft.trim()}>{addVerifiedEmail.isPending ? "添加中" : "添加"}</Button>
|
<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>
|
||||||
</form>
|
</form>
|
||||||
<div className="mx-8 mt-6 max-h-[360px] overflow-y-auto rounded-lg border">
|
<div className="mx-8 mt-6 max-h-[380px] space-y-4 overflow-y-auto pr-1">
|
||||||
{verifiedEmailItems.map((item) => (
|
{pendingVerifiedEmailItems.length > 0 && (
|
||||||
<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="rounded-lg border">
|
||||||
<div className="min-w-0">
|
<div className="border-b bg-muted/30 px-4 py-3 text-sm font-medium">待验证 ({pendingVerifiedEmailItems.length})</div>
|
||||||
<div className="truncate text-lg font-semibold leading-6">{item.email}</div>
|
{matchingPendingVerifiedEmailItems.map((item) => (
|
||||||
<div className="mt-1 text-sm text-muted-foreground">{item.verified ? `已验证 - ${formatDateTime(item.verifiedAt || item.createdAt)}` : "待验证"}</div>
|
<VerifiedEmailRow key={item.id} item={item} busy={forwardingBusy} onResend={resendVerification} onRemove={removeVerifiedEmail} />
|
||||||
{!item.verified && (
|
))}
|
||||||
<div className={cn("mt-1 text-sm leading-5", forwardingEmailTone(item).detailClass)}>
|
{matchingPendingVerifiedEmailItems.length === 0 && <div className="px-4 py-8 text-center text-sm text-muted-foreground">待验证邮箱中没有匹配地址</div>}
|
||||||
{forwardingEmailStatusText(item)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
{verifiedEmailItems.length === 0 && <div className="py-10 text-center text-sm text-muted-foreground">暂无验证邮箱</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" />}
|
||||||
|
</Button>
|
||||||
|
{(verifiedEmailsExpanded || !!normalizedVerifiedEmailDraft) && (
|
||||||
|
<div className="border-t">
|
||||||
|
{matchingCompletedVerifiedEmailItems.map((item) => (
|
||||||
|
<VerifiedEmailRow key={item.id} item={item} busy={forwardingBusy} onResend={resendVerification} onRemove={removeVerifiedEmail} />
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="border-t px-8 py-6">
|
<DialogFooter className="border-t px-8 py-6">
|
||||||
<Button type="button" variant="outline" className="h-12 px-8 text-base" onClick={() => setVerifiedDialogOpen(false)}>关闭</Button>
|
<Button type="button" variant="outline" className="h-12 px-8 text-base" onClick={() => { setVerifiedDialogOpen(false); setVerifiedEmailDraft("") }}>关闭</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -1632,36 +1718,81 @@ function formatDateTime(value: string) {
|
|||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ForwardingTargetSummary({ targets, prefix = "转发:", onView }: { targets: string[]; prefix?: string; onView: () => void }) {
|
||||||
|
if (targets.length === 0) return null
|
||||||
|
const prefixText = prefix.endsWith(":") ? prefix : `${prefix} `
|
||||||
|
return (
|
||||||
|
<span className="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold text-foreground" title={`${prefixText}${targets.join("、")}`}>
|
||||||
|
<span className="min-w-0 truncate">{prefixText}{targets[0]}</span>
|
||||||
|
{targets.length > 1 && (
|
||||||
|
<Button type="button" variant="link" className="h-auto shrink-0 px-1 py-0 text-xs font-semibold" onClick={onView}>
|
||||||
|
查看全部 {targets.length} 个
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForwardingTarget(value: string) {
|
||||||
|
return value.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortForwardingTargets(targets: string[]) {
|
||||||
|
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean))).sort((a, b) => forwardingTargetCollator.compare(a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeForwardingTargets(...groups: string[][]) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const merged: string[] = []
|
||||||
|
for (const target of groups.flat()) {
|
||||||
|
const value = target.trim()
|
||||||
|
const key = normalizeForwardingTarget(value)
|
||||||
|
if (!value || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
merged.push(value)
|
||||||
|
}
|
||||||
|
return sortForwardingTargets(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutForwardingTargets(targets: string[], lockedTargets: string[]) {
|
||||||
|
const locked = new Set(lockedTargets.map(normalizeForwardingTarget))
|
||||||
|
return sortForwardingTargets(targets.filter((target) => !locked.has(normalizeForwardingTarget(target))))
|
||||||
|
}
|
||||||
|
|
||||||
function forwardingTargetsFromRule(rule: { targetEmail?: string; targetEmails?: string[] }) {
|
function forwardingTargetsFromRule(rule: { targetEmail?: string; targetEmails?: string[] }) {
|
||||||
const targets = rule.targetEmails?.length ? rule.targetEmails : rule.targetEmail ? [rule.targetEmail] : []
|
const targets = rule.targetEmails?.length ? rule.targetEmails : rule.targetEmail ? [rule.targetEmail] : []
|
||||||
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
|
return sortForwardingTargets(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
function forwardingTargetsFromSettings(settings?: ForwardingSettings) {
|
function forwardingTargetsFromSettings(settings?: ForwardingSettings) {
|
||||||
const targets = settings?.accountTargetEmails?.length ? settings.accountTargetEmails : settings?.accountTargetEmail ? [settings.accountTargetEmail] : []
|
const targets = settings?.accountTargetEmails?.length ? settings.accountTargetEmails : settings?.accountTargetEmail ? [settings.accountTargetEmail] : []
|
||||||
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
|
return sortForwardingTargets(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ForwardingTargetPicker({ emails, selected, onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
|
function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedLabel = "账号级", onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; lockedSelected?: string[]; lockedLabel?: string; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const [query, setQuery] = React.useState("")
|
const [query, setQuery] = React.useState("")
|
||||||
const selectedSet = React.useMemo(() => new Set(selected), [selected])
|
const lockedEmails = React.useMemo(() => sortForwardingTargets(lockedSelected), [lockedSelected])
|
||||||
|
const lockedSet = React.useMemo(() => new Set(lockedEmails.map(normalizeForwardingTarget)), [lockedEmails])
|
||||||
|
const selectedEmailsOnly = React.useMemo(() => withoutForwardingTargets(selected, lockedEmails), [lockedEmails, selected])
|
||||||
|
const selectedSet = React.useMemo(() => new Set(selectedEmailsOnly.map(normalizeForwardingTarget)), [selectedEmailsOnly])
|
||||||
const sortedEmails = React.useMemo(() => {
|
const sortedEmails = React.useMemo(() => {
|
||||||
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
|
return sortForwardingTargets(emails)
|
||||||
return Array.from(new Set(emails.map((email) => email.trim()).filter(Boolean))).sort((a, b) => collator.compare(a, b))
|
|
||||||
}, [emails])
|
}, [emails])
|
||||||
const selectedEmails = React.useMemo(() => sortedEmails.filter((email) => selectedSet.has(email)), [selectedSet, sortedEmails])
|
const regularEmails = React.useMemo(() => sortedEmails.filter((email) => !lockedSet.has(normalizeForwardingTarget(email))), [lockedSet, sortedEmails])
|
||||||
|
const selectedEmails = React.useMemo(() => [...lockedEmails, ...regularEmails.filter((email) => selectedSet.has(normalizeForwardingTarget(email)))], [lockedEmails, regularEmails, selectedSet])
|
||||||
const normalizedQuery = query.trim().toLowerCase()
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
const filteredEmails = normalizedQuery ? sortedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : sortedEmails
|
const filteredLockedEmails = normalizedQuery ? lockedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : lockedEmails
|
||||||
|
const filteredEmails = normalizedQuery ? regularEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : regularEmails
|
||||||
const label = selectedEmails.length
|
const label = selectedEmails.length
|
||||||
? `已选择 ${selectedEmails.length} 个:${selectedEmails.slice(0, 2).join("、")}${selectedEmails.length > 2 ? ` 等 ${selectedEmails.length} 个` : ""}`
|
? `已选择 ${selectedEmails.length} 个:${selectedEmails.slice(0, 2).join("、")}${selectedEmails.length > 2 ? ` 等 ${selectedEmails.length} 个` : ""}`
|
||||||
: "不转发,点击选择邮箱"
|
: "不转发,点击选择邮箱"
|
||||||
function toggle(email: string, checked: boolean) {
|
function toggle(email: string, checked: boolean) {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
const next = checked ? Array.from(new Set([...selected, email])) : selected.filter((item) => item !== email)
|
const next = checked ? mergeForwardingTargets(selectedEmailsOnly, [email]) : selectedEmailsOnly.filter((item) => normalizeForwardingTarget(item) !== normalizeForwardingTarget(email))
|
||||||
onChange(next)
|
onChange(withoutForwardingTargets(next, lockedEmails))
|
||||||
}
|
}
|
||||||
if (emails.length === 0) {
|
if (sortedEmails.length === 0 && lockedEmails.length === 0) {
|
||||||
return <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground">暂无已验证邮箱</div>
|
return <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground">暂无已验证邮箱</div>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -1693,7 +1824,7 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
<Button type="button" variant="ghost" size="icon" className="absolute right-0.5 top-0.5 size-7 text-muted-foreground" onClick={() => setQuery("")}>
|
<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("")}>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1704,8 +1835,18 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 max-h-[132px] overflow-y-auto pr-1">
|
<div className="mt-2 max-h-[132px] overflow-y-auto pr-1">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
{filteredLockedEmails.map((email) => (
|
||||||
|
<label
|
||||||
|
key={`locked-${email}`}
|
||||||
|
className="flex min-h-[36px] cursor-not-allowed items-center gap-2 rounded-md border border-border bg-muted/70 px-2.5 py-1.5 text-sm font-semibold text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Checkbox checked disabled />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{email}</span>
|
||||||
|
<Badge variant="outline" className="h-5 shrink-0 rounded px-1.5 text-[10px]">{lockedLabel}</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
{filteredEmails.map((email) => {
|
{filteredEmails.map((email) => {
|
||||||
const checked = selectedSet.has(email)
|
const checked = selectedSet.has(normalizeForwardingTarget(email))
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={email}
|
key={email}
|
||||||
@@ -1719,7 +1860,7 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground">没有匹配的已验证邮箱</div>}
|
{filteredLockedEmails.length === 0 && filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground">没有匹配的已验证邮箱</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1728,35 +1869,55 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function forwardingEmailTone(item: ForwardingVerifiedEmail) {
|
||||||
if (item.verified) {
|
if (item.verified) {
|
||||||
return {
|
return {
|
||||||
shortLabel: "已验证",
|
|
||||||
dotClass: "bg-emerald-500",
|
dotClass: "bg-emerald-500",
|
||||||
chipClass: "bg-emerald-100 text-emerald-800",
|
|
||||||
detailClass: "text-emerald-700",
|
detailClass: "text-emerald-700",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (item.deliveryStatus === "failed") {
|
if (item.deliveryStatus === "failed") {
|
||||||
return {
|
return {
|
||||||
shortLabel: "发送失败",
|
|
||||||
dotClass: "bg-destructive",
|
dotClass: "bg-destructive",
|
||||||
chipClass: "bg-destructive/10 text-destructive",
|
|
||||||
detailClass: "text-destructive",
|
detailClass: "text-destructive",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (item.deliveryStatus === "delivered") {
|
if (item.deliveryStatus === "delivered") {
|
||||||
return {
|
return {
|
||||||
shortLabel: "待验证",
|
|
||||||
dotClass: "bg-amber-500",
|
dotClass: "bg-amber-500",
|
||||||
chipClass: "bg-amber-100 text-amber-800",
|
|
||||||
detailClass: "text-amber-700",
|
detailClass: "text-amber-700",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
shortLabel: "待验证",
|
|
||||||
dotClass: "bg-muted-foreground",
|
dotClass: "bg-muted-foreground",
|
||||||
chipClass: "bg-muted text-foreground",
|
|
||||||
detailClass: "text-foreground",
|
detailClass: "text-foreground",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1844,7 +2005,7 @@ function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelec
|
|||||||
<div className="text-muted-foreground">用户名</div>
|
<div className="text-muted-foreground">用户名</div>
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||||
<span className="truncate text-right sm:text-left">{selected.address}</span>
|
<span className="truncate text-right sm:text-left">{selected.address}</span>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="复制邮箱地址" title="复制邮箱地址" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">密码</div>
|
<div className="text-muted-foreground">密码</div>
|
||||||
<div>邮箱登录密码</div>
|
<div>邮箱登录密码</div>
|
||||||
@@ -1868,7 +2029,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>
|
<code className="truncate rounded border bg-background px-2 py-1 text-xs">{value}</code>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<span className="text-xs font-medium text-emerald-600">{security}</span>
|
<span className="text-xs font-medium text-emerald-600">{security}</span>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2035,8 +2196,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 className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<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" 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" 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 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -2204,8 +2365,8 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
<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" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" 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>
|
<Button type="button" variant="ghost" size="icon" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2221,8 +2382,8 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
|
<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>
|
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" 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>}
|
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2407,7 +2568,7 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
|
|||||||
<div className="truncate text-sm font-semibold text-foreground">{item.email}</div>
|
<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 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>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && <EmptyState icon={<ShieldCheck />} text="没有被拦截的邮件" description="当前没有发件人拦截规则,所有邮件都会按正常规则投递。" />}
|
{items.length === 0 && <EmptyState icon={<ShieldCheck />} text="没有被拦截的邮件" description="当前没有发件人拦截规则,所有邮件都会按正常规则投递。" />}
|
||||||
@@ -2678,10 +2839,10 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
|
|||||||
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onToggleTheme}>
|
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
|
||||||
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onBack}>
|
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useToast } from "@/hooks/use-toast"
|
|||||||
import { PasswordInput } from "@/components/ui/password-input"
|
import { PasswordInput } from "@/components/ui/password-input"
|
||||||
import { TurnstileBox } from "@/components/turnstile-box"
|
import { TurnstileBox } from "@/components/turnstile-box"
|
||||||
import { validatePasswordConfirm } from "@/lib/validation"
|
import { validatePasswordConfirm } from "@/lib/validation"
|
||||||
|
import { AuthError, AuthLoading } from "@/components/auth-states"
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@@ -57,8 +58,10 @@ export function RegisterPage() {
|
|||||||
})
|
})
|
||||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||||
if (me.data?.user) return <Navigate to="/" replace />
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
<main 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="w-full max-w-[420px]">
|
||||||
<div className="mb-7 text-center">
|
<div className="mb-7 text-center">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||||
@@ -103,7 +106,6 @@ export function RegisterPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="displayName" className="text-sm font-medium">显示名称</Label>
|
<Label htmlFor="displayName" className="text-sm font-medium">显示名称</Label>
|
||||||
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||||
@@ -130,6 +132,6 @@ export function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-13
@@ -1,19 +1,44 @@
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig, loadEnv } from "vite"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd(), "")
|
||||||
resolve: {
|
const apiTarget = env.VITE_API_TARGET || "http://localhost:8080"
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
server: {
|
port: 5173,
|
||||||
port: 5173,
|
proxy: {
|
||||||
proxy: {
|
"/api": apiTarget,
|
||||||
"/api": "http://localhost:8080",
|
"/healthz": apiTarget,
|
||||||
"/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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+26
-19
@@ -42,12 +42,12 @@
|
|||||||
| NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 |
|
| NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 |
|
||||||
| NSX-20260806-006 | 2026-08-06 | 已完成 | 后端/通知;邮件核心;前端/设置;质量复核 | Telegram 邮件通知安全、验证码复制和可靠性复核 | S2 | v1.2.17 | 随 v1.2.17 发布 |
|
| NSX-20260806-006 | 2026-08-06 | 已完成 | 后端/通知;邮件核心;前端/设置;质量复核 | Telegram 邮件通知安全、验证码复制和可靠性复核 | S2 | v1.2.17 | 随 v1.2.17 发布 |
|
||||||
| NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 |
|
| 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 | 待发布 |
|
| NSX-20260807-008 | 2026-08-07 | 已完成 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||||
| NSX-20260807-009 | 2026-08-07 | 待验收 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | 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 | 待发布 |
|
| NSX-20260807-010 | 2026-08-07 | 已完成 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 随 v1.2.19 发布,线上 DKIM 已验收通过 |
|
||||||
| NSX-20260807-011 | 2026-08-07 | 待验收 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | 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 | 待发布 |
|
| 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 | 待发布 |
|
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
|
||||||
|
|
||||||
## NSX-20260804-001
|
## NSX-20260804-001
|
||||||
|
|
||||||
@@ -289,14 +289,14 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 编号 | NSX-20260807-008 |
|
| 编号 | NSX-20260807-008 |
|
||||||
| 日期 | 2026-08-07 |
|
| 日期 | 2026-08-07 |
|
||||||
| 状态 | 待验收 |
|
| 状态 | 已完成 |
|
||||||
| 模块 | 前端/UI;邮件发送;部署运维/安装;质量复核 |
|
| 模块 | 前端/UI;邮件发送;部署运维/安装;质量复核 |
|
||||||
| 现象 | “全部邮箱”状态写信时固定使用列表第一项,无法选择发件邮箱;写信窗口在桌面端过宽;安装时邮件服务器域名、邮箱地址域名和管理员邮箱前缀的关系不够直观。 |
|
| 现象 | “全部邮箱”状态写信时固定使用列表第一项,无法选择发件邮箱;写信窗口在桌面端过宽;安装时邮件服务器域名、邮箱地址域名和管理员邮箱前缀的关系不够直观。 |
|
||||||
| 根因 | 全部邮箱状态直接将邮箱列表第一项传入写信组件,组件只支持只读展示单个邮箱;窗口最大宽度为 82rem。 |
|
| 根因 | 全部邮箱状态直接将邮箱列表第一项传入写信组件,组件只支持只读展示单个邮箱;窗口最大宽度为 82rem。 |
|
||||||
| 实现 | “全部邮箱”写信默认优先匹配当前登录邮箱,并可在全部有效邮箱中切换;回复、转发沿用原邮件所属邮箱;发送、定时发送和草稿自动保存统一使用当前选择的发件邮箱;桌面写信窗口参照 Seek 收窄至最大 72rem;表单仅在新写信会话开始时初始化,切换邮箱或加载签名不会清空已填写内容;安装脚本回归测试明确覆盖 mail.newszxcn.com 对应 admin@newszxcn.com。 |
|
| 实现 | “全部邮箱”写信默认优先匹配当前登录邮箱,并可在全部有效邮箱中切换;回复、转发沿用原邮件所属邮箱;发送、定时发送和草稿自动保存统一使用当前选择的发件邮箱;桌面写信窗口参照 Seek 收窄至最大 72rem;表单仅在新写信会话开始时初始化,切换邮箱或加载签名不会清空已填写内容;安装脚本回归测试明确覆盖 mail.newszxcn.com 对应 admin@newszxcn.com。 |
|
||||||
| 目标版本 | v1.2.19 |
|
| 目标版本 | v1.2.19 |
|
||||||
| 测试结果 | 前端 check/build、Go 全量测试与 go vet、安装脚本测试、bash 语法检查、ShellCheck、git diff 检查均通过;本地双邮箱页面实测默认选择登录邮箱,下拉项完整,切换邮箱后收件人、主题、正文均保留,数据库确认草稿保存到新选择的邮箱;桌面截图确认写信窗口无溢出和遮挡。 |
|
| 测试结果 | 前端 check/build、Go 全量测试与 go vet、安装脚本测试、bash 语法检查、ShellCheck、git diff 检查均通过;本地双邮箱页面实测默认选择登录邮箱,下拉项完整,切换邮箱后收件人、主题、正文均保留,数据库确认草稿保存到新选择的邮箱;桌面截图确认写信窗口无溢出和遮挡。 |
|
||||||
| 发布状态 | 待发布。 |
|
| 发布状态 | 随 v1.2.19 发布。 |
|
||||||
|
|
||||||
### 历史
|
### 历史
|
||||||
|
|
||||||
@@ -304,6 +304,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 |
|
| 2026-08-07 | 用户反馈全部邮箱写信的默认发件箱、邮箱选择和窗口宽度问题,进入处理中。 |
|
||||||
| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 |
|
| 2026-08-07 | 完成写信邮箱选择、默认项、回复/转发邮箱、草稿归属、窗口宽度和安装管理员邮箱交互修改;自动化与页面验收通过,状态流转为待验收。 |
|
||||||
|
| 2026-08-07 | v1.2.19 检查、六个 Docker 镜像、GitHub Release 和 Telegram 频道通知全部成功,状态流转为已完成。 |
|
||||||
|
|
||||||
## NSX-20260807-009
|
## NSX-20260807-009
|
||||||
|
|
||||||
@@ -311,7 +312,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 编号 | NSX-20260807-009 |
|
| 编号 | NSX-20260807-009 |
|
||||||
| 日期 | 2026-08-07 |
|
| 日期 | 2026-08-07 |
|
||||||
| 状态 | 待验收 |
|
| 状态 | 已完成 |
|
||||||
| 模块 | 部署运维/安装;文档;质量复核 |
|
| 模块 | 部署运维/安装;文档;质量复核 |
|
||||||
| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 |
|
| 现象 | 空白服务器仍显示更新、回滚、重启等不可用操作;已安装菜单没有运行状态和实际版本;部分直接命令缺少统一安装前置检查;安装残缺时没有明确修复入口。 |
|
||||||
| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 |
|
| 根因 | 主菜单只根据 `.env` 切换默认选项,所有状态共用一套菜单;运行状态、镜像版本和安装完整性没有独立判断;部分前置检查散落在菜单分发层。 |
|
||||||
@@ -319,7 +320,7 @@
|
|||||||
| 兼容性 | 保持既有菜单编号 `1–12` 和 `ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 |
|
| 兼容性 | 保持既有菜单编号 `1–12` 和 `ns` 快捷命令;现有配置、数据库、邮件、证书和更新流程不变。 |
|
||||||
| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 |
|
| 测试结果 | Bash 语法检查、ShellCheck、安装脚本全量测试、菜单渲染/范围/分发/状态/版本/残缺安装/前置条件测试、前端 check/build、Go 全量测试和 go vet 均通过。 |
|
||||||
| 目标版本 | v1.2.19 |
|
| 目标版本 | v1.2.19 |
|
||||||
| 发布状态 | 待发布。 |
|
| 发布状态 | 随 v1.2.19 发布。 |
|
||||||
|
|
||||||
### 历史
|
### 历史
|
||||||
|
|
||||||
@@ -327,6 +328,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 |
|
| 2026-08-07 | 用户确认按安装状态拆分主菜单,并要求写入仓库介绍、复核全部命令逻辑。 |
|
||||||
| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 |
|
| 2026-08-07 | 完成动态菜单、命令前置条件、残缺安装修复入口、README 和自动化回归,状态流转为待验收。 |
|
||||||
|
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
|
||||||
|
|
||||||
## NSX-20260807-010
|
## NSX-20260807-010
|
||||||
|
|
||||||
@@ -334,16 +336,16 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 编号 | NSX-20260807-010 |
|
| 编号 | NSX-20260807-010 |
|
||||||
| 日期 | 2026-08-07 |
|
| 日期 | 2026-08-07 |
|
||||||
| 状态 | 待验收 |
|
| 状态 | 已完成 |
|
||||||
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
|
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
|
||||||
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
|
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
|
||||||
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
|
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
|
||||||
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
|
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
|
||||||
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
|
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
|
||||||
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
|
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
|
||||||
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上仍需升级后重新发信,确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上 DKIM 已验收通过。 |
|
||||||
| 目标版本 | v1.2.19 |
|
| 目标版本 | v1.2.19 |
|
||||||
| 发布状态 | 待发布。 |
|
| 发布状态 | 已随 v1.2.19 发布,线上 DKIM 已验收通过。 |
|
||||||
|
|
||||||
### 历史
|
### 历史
|
||||||
|
|
||||||
@@ -351,6 +353,8 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
|
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
|
||||||
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
|
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
|
||||||
|
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
|
||||||
|
| 2026-08-08 | 用户确认 DKIM 已通过,状态流转为已完成。 |
|
||||||
|
|
||||||
## NSX-20260807-011
|
## NSX-20260807-011
|
||||||
|
|
||||||
@@ -358,14 +362,14 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 编号 | NSX-20260807-011 |
|
| 编号 | NSX-20260807-011 |
|
||||||
| 日期 | 2026-08-07 |
|
| 日期 | 2026-08-07 |
|
||||||
| 状态 | 待验收 |
|
| 状态 | 已完成 |
|
||||||
| 模块 | 后端/通知;验证码识别;质量复核 |
|
| 模块 | 后端/通知;验证码识别;质量复核 |
|
||||||
| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 |
|
| 现象 | 爱奇艺邮件的主题和正文均包含验证码 `825534`,Telegram 通知却没有独立验证码区域和“复制验证码”按钮。 |
|
||||||
| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02`、`newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 |
|
| 根因 | 正文开头的收件地址 `iqiyi02@newszxcn.com` 被拆成 `iqiyi02`、`newszxcn` 两个候选;它们与主题中的“验证码”距离较近,触发多候选歧义保护后返回空结果。 |
|
||||||
| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 |
|
| 实现 | 验证码评分前排除邮箱地址和 HTTP/HTTPS 链接范围内的字母数字片段,保留真实正文与主题候选;新增爱奇艺原始场景回归,同时检查独立验证码区域和复制按钮。 |
|
||||||
| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 |
|
| 测试结果 | 爱奇艺原始场景、Gate 验证码与链接、Telegram 消息预算专项测试及 Go 全量测试均通过。 |
|
||||||
| 目标版本 | v1.2.19 |
|
| 目标版本 | v1.2.19 |
|
||||||
| 发布状态 | 待发布。 |
|
| 发布状态 | 随 v1.2.19 发布。 |
|
||||||
|
|
||||||
### 历史
|
### 历史
|
||||||
|
|
||||||
@@ -373,6 +377,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 |
|
| 2026-08-07 | 用户提供 Telegram 实际通知截图,完成邮箱地址候选冲突复现。 |
|
||||||
| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 |
|
| 2026-08-07 | 修复邮箱和链接候选排除逻辑,加入爱奇艺验证码专项回归。 |
|
||||||
|
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
|
||||||
|
|
||||||
## NSX-20260807-012
|
## NSX-20260807-012
|
||||||
|
|
||||||
@@ -380,13 +385,13 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 编号 | NSX-20260807-012 |
|
| 编号 | NSX-20260807-012 |
|
||||||
| 日期 | 2026-08-07 |
|
| 日期 | 2026-08-07 |
|
||||||
| 状态 | 待验收 |
|
| 状态 | 已完成 |
|
||||||
| 模块 | 前端/UI;域名管理;质量复核 |
|
| 模块 | 前端/UI;域名管理;质量复核 |
|
||||||
| 现象 | DNS 记录顶部“复制”会得到 `TXT newszxcn.com v=spf1 mx -all`,不能直接粘贴到域名服务商的主机记录和记录值输入框。 |
|
| 现象 | DNS 记录顶部“复制”会得到 `TXT newszxcn.com v=spf1 mx -all`,不能直接粘贴到域名服务商的主机记录和记录值输入框。 |
|
||||||
| 实现 | 删除整行复制;每条记录明确展示记录类型、主机记录、记录值和 TTL;主机记录与记录值分别提供图标复制和对应成功提示,TTL 仅展示。 |
|
| 实现 | 删除整行复制;每条记录明确展示记录类型、主机记录、记录值和 TTL;主机记录与记录值分别提供图标复制和对应成功提示,TTL 仅展示。 |
|
||||||
| 测试结果 | 前端 check/build 通过;本地页面确认 SPF 主机记录和记录值分别复制为 `lanqin.local` 与 `v=spf1 mx -all`,DKIM 长记录正常换行且弹窗没有横向溢出。 |
|
| 测试结果 | 前端 check/build 通过;本地页面确认 SPF 主机记录和记录值分别复制为 `lanqin.local` 与 `v=spf1 mx -all`,DKIM 长记录正常换行且弹窗没有横向溢出。 |
|
||||||
| 目标版本 | v1.2.19 |
|
| 目标版本 | v1.2.19 |
|
||||||
| 发布状态 | 待发布。 |
|
| 发布状态 | 随 v1.2.19 发布。 |
|
||||||
|
|
||||||
### 历史
|
### 历史
|
||||||
|
|
||||||
@@ -394,6 +399,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 |
|
| 2026-08-07 | 用户提供 DNS 弹窗截图并指出整行复制无法直接用于 DNS 面板。 |
|
||||||
| 2026-08-07 | 完成主机记录和记录值分离复制。 |
|
| 2026-08-07 | 完成主机记录和记录值分离复制。 |
|
||||||
|
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
|
||||||
|
|
||||||
## NSX-20260807-013
|
## NSX-20260807-013
|
||||||
|
|
||||||
@@ -401,14 +407,14 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 编号 | NSX-20260807-013 |
|
| 编号 | NSX-20260807-013 |
|
||||||
| 日期 | 2026-08-07 |
|
| 日期 | 2026-08-07 |
|
||||||
| 状态 | 待验收 |
|
| 状态 | 已完成 |
|
||||||
| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 |
|
| 模块 | 邮件核心;前端/标签;数据迁移;质量复核 |
|
||||||
| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 |
|
| 需求 | 邮箱默认增加个人、家人、朋友、工作、重要五个常用标签,并按名称使用容易辨认的颜色。 |
|
||||||
| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 |
|
| 实现 | 新邮箱创建时由后端事务生成五个标签;已有邮箱升级时一次性补齐;固定顺序为个人、家人、朋友、工作、重要,颜色依次为绿色、玫红、青色、蓝色、橙色;用户后续删除标签不会在重启时恢复;“全部邮箱”按名称合并同名标签并汇总数量,点击或导出时覆盖用户名下所有邮箱。 |
|
||||||
| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 |
|
| 兼容性 | 使用 `INSERT OR IGNORE` 保留已有同名标签及其颜色和邮件关联;不修改用户自建标签。 |
|
||||||
| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 |
|
| 测试结果 | 新邮箱创建、旧邮箱补齐、删除后不重建、多邮箱同名汇总与跨邮箱筛选专项测试,以及 Go 全量测试和 vet 均通过;页面确认五个标签各显示一次、顺序正确,颜色与侧栏宽度正确且无越界。 |
|
||||||
| 目标版本 | v1.2.19 |
|
| 目标版本 | v1.2.19 |
|
||||||
| 发布状态 | 待发布。 |
|
| 发布状态 | 随 v1.2.19 发布。 |
|
||||||
|
|
||||||
### 历史
|
### 历史
|
||||||
|
|
||||||
@@ -416,3 +422,4 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 |
|
| 2026-08-07 | 用户提供标签侧栏参考并指定五个默认标签。 |
|
||||||
| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
|
| 2026-08-07 | 完成新邮箱默认生成、已有邮箱一次性补齐、固定排序和颜色回归;页面复核时发现全部邮箱重复显示,继续完成同名汇总、跨邮箱筛选与导出回归。 |
|
||||||
|
| 2026-08-07 | 随 v1.2.19 完成发布,状态流转为已完成。 |
|
||||||
|
|||||||
Generated
+5
-5
@@ -81,8 +81,8 @@ importers:
|
|||||||
specifier: 2.1.1
|
specifier: 2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: 3.4.12
|
specifier: 3.4.13
|
||||||
version: 3.4.12
|
version: 3.4.13
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(react@18.3.1)
|
version: 0.468.0(react@18.3.1)
|
||||||
@@ -1022,8 +1022,8 @@ packages:
|
|||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
dompurify@3.4.12:
|
dompurify@3.4.13:
|
||||||
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
|
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.375:
|
electron-to-chromium@1.5.375:
|
||||||
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
|
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
|
||||||
@@ -2447,7 +2447,7 @@ snapshots:
|
|||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
dompurify@3.4.12:
|
dompurify@3.4.13:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user