Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce98978ebd | |||
| ee38990ea1 | |||
| 9cb3f13b02 | |||
| d2cfad3cc4 |
@@ -0,0 +1,5 @@
|
|||||||
|
- 优化转发验证完成页:移除“返回邮箱”入口,外部收件人确认 Netflix、ChatGPT 等验证码转发授权后不会进入邮箱登录页,只显示验证结果和关闭页面提示。
|
||||||
|
- 合并验证邮箱搜索与添加入口:输入内容会实时筛选已添加地址,输入新邮箱时可直接发送验证邮件,已存在地址会明确显示为“已添加”。
|
||||||
|
- 重整验证邮箱管理列表:待验证邮箱置顶展示,已验证邮箱按数字和字母排序并聚合为可折叠分组,邮箱数量较多时仍便于查找和管理。
|
||||||
|
- 精简邮件转发主页面:不再平铺全部验证邮箱标签,改为显示已验证与待验证数量汇总,点击即可进入管理列表。
|
||||||
|
- 补充验证完成页回归测试,确保页面不再出现邮箱首页或登录入口,并完成前端构建、组件规范、后端全量测试与静态检查。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- 精简邮件转发页面入口,移除右上角重复的“管理验证邮箱”按钮。
|
||||||
|
- 保留下方汇总入口并统一命名为“管理验证邮箱”,继续显示已验证与待验证邮箱数量。
|
||||||
|
- 没有验证邮箱时仍显示该入口,用户可直接进入添加和验证邮箱。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。
|
||||||
|
- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。
|
||||||
|
- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。
|
||||||
|
- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。
|
||||||
|
- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。
|
||||||
|
- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。
|
||||||
|
- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。
|
||||||
|
- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。
|
||||||
|
- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- 修复手机端删除自定义文件夹时菜单先消失、确认框无法显示的问题,文件夹菜单现在与移动侧栏保持在同一交互层中。
|
||||||
|
- 删除文件夹前会稳定显示确认信息,提交后显示处理中状态,避免重复操作;文件夹内邮件仍会安全移回收件箱。
|
||||||
|
- 修复手机侧栏中新建文件夹弹窗被侧栏遮挡或立即关闭的问题,侧栏退出后再打开创建界面。
|
||||||
|
- 重做手机端“新建规则 / 编辑规则”布局:标题、表单与底部操作区改为纵向结构,内容区域可独立滚动。
|
||||||
|
- 优化规则条件与动作的窄屏排列,字段和运算符并排、输入框独占一行,添加与删除按钮保持易点击且不会挤出屏幕。
|
||||||
|
- 规则底部创建和取消按钮固定可见并适配手机安全区域,多条件、多动作时仍可顺畅滚动和提交。
|
||||||
|
- 优化通用确认弹窗的手机宽度和按钮触控尺寸,减少误触并避免贴边显示。
|
||||||
@@ -317,6 +317,7 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
|
icon TEXT NOT NULL DEFAULT 'folder',
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -690,6 +691,9 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateFolderIcons(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.migrateExternalIMAP(ctx); err != nil {
|
if err := a.migrateExternalIMAP(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1050,6 +1050,191 @@ func TestMailRulesForwardingAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMailRulesExactSenderCustomFolderAndStopProcessing(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ts := httptest.NewServer(a.Router())
|
||||||
|
defer ts.Close()
|
||||||
|
admin := &testClient{t: t, server: ts}
|
||||||
|
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("admin login code=%d", code)
|
||||||
|
}
|
||||||
|
domainID := mustDefaultDomainID(t, a)
|
||||||
|
sender := createTestMailbox(t, admin, domainID, "rule-exact-sender", "Sender With Name", "Password123!", nil)
|
||||||
|
recipient := createTestMailbox(t, admin, domainID, "rule-custom-target", "Rule Target", "Password123!", nil)
|
||||||
|
|
||||||
|
rcpt := &testClient{t: t, server: ts}
|
||||||
|
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("recipient login=%d", code)
|
||||||
|
}
|
||||||
|
var bad map[string]any
|
||||||
|
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||||
|
"mailboxId": recipient.ID,
|
||||||
|
"conditions": []map[string]string{{"field": "size", "operator": "contains", "value": "10"}},
|
||||||
|
"actions": []map[string]string{{"type": "archive"}},
|
||||||
|
}, &bad); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid field operator should be rejected code=%d body=%v", code, bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRule := func(name string, action map[string]string, stop bool) {
|
||||||
|
t.Helper()
|
||||||
|
var rule MailRule
|
||||||
|
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||||
|
"mailboxId": recipient.ID,
|
||||||
|
"name": name,
|
||||||
|
"conditions": []map[string]string{{"field": "from", "operator": "equals", "value": sender.Address}},
|
||||||
|
"actions": []map[string]string{action},
|
||||||
|
"stopProcessing": stop,
|
||||||
|
}, &rule); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create rule %s code=%d rule=%+v", name, code, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createRule("fallback archive", map[string]string{"type": "archive"}, false)
|
||||||
|
createRule("Netflix folder", map[string]string{"type": "move", "value": "Netflix 验证码"}, true)
|
||||||
|
|
||||||
|
senderClient := &testClient{t: t, server: ts}
|
||||||
|
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("sender login=%d", code)
|
||||||
|
}
|
||||||
|
var sent MailMessage
|
||||||
|
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": "Netflix code", "text": "123456"}, &sent); code != http.StatusCreated {
|
||||||
|
t.Fatalf("send code=%d sent=%+v", code, sent)
|
||||||
|
}
|
||||||
|
var custom struct {
|
||||||
|
Items []MailMessage `json:"items"`
|
||||||
|
}
|
||||||
|
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder="+url.QueryEscape("Netflix 验证码"), nil, &custom); code != http.StatusOK || len(custom.Items) != 1 {
|
||||||
|
t.Fatalf("custom rule folder code=%d items=%+v", code, custom.Items)
|
||||||
|
}
|
||||||
|
var archived struct {
|
||||||
|
Items []MailMessage `json:"items"`
|
||||||
|
}
|
||||||
|
if code := rcpt.do("GET", "/api/mail/messages?mailboxId="+recipient.ID+"&folder=Archive", nil, &archived); code != http.StatusOK || len(archived.Items) != 0 {
|
||||||
|
t.Fatalf("stop processing should prevent fallback archive code=%d items=%+v", code, archived.Items)
|
||||||
|
}
|
||||||
|
var icon string
|
||||||
|
if err := a.db.QueryRow(`SELECT icon FROM folders WHERE mailbox_id=? AND name=?`, recipient.ID, "Netflix 验证码").Scan(&icon); err != nil || icon != "netflix" {
|
||||||
|
t.Fatalf("rule-created folder icon=%q err=%v", icon, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderIconForName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
requested string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "Netflix 验证码", requested: "auto", want: "netflix"},
|
||||||
|
{name: "ChatGPT 通知", want: "chatgpt"},
|
||||||
|
{name: "OpenAI 账单", want: "chatgpt"},
|
||||||
|
{name: "项目归档", want: "briefcase"},
|
||||||
|
{name: "其他", want: "folder"},
|
||||||
|
{name: "Netflix", requested: "heart", want: "heart"},
|
||||||
|
{name: "Netflix", requested: "unknown", want: "folder"},
|
||||||
|
{name: "Custom", requested: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", want: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="},
|
||||||
|
{name: "Egypt archive", want: "folder"},
|
||||||
|
{name: "Custom", requested: "data:image/svg+xml;base64,PHN2Zz4=", want: "folder"},
|
||||||
|
{name: "Custom", requested: "data:image/png;base64,SGVsbG8=", want: "folder"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name+"/"+tt.requested, func(t *testing.T) {
|
||||||
|
if got := folderIconForName(tt.name, tt.requested); got != tt.want {
|
||||||
|
t.Fatalf("folderIconForName(%q, %q)=%q want %q", tt.name, tt.requested, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleFolderAutoIconPreservesManualSelection(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := &testClient{t: t, server: httptest.NewServer(a.Router())}
|
||||||
|
defer admin.server.Close()
|
||||||
|
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("admin login code=%d", code)
|
||||||
|
}
|
||||||
|
domainID := createTestDomain(t, admin, "manual-icon.test")
|
||||||
|
mailbox := createTestMailbox(t, admin, domainID.ID, "rules", "Rules", "Password123!", nil)
|
||||||
|
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "heart"); err != nil {
|
||||||
|
t.Fatalf("create custom folder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := a.ensureCustomFolder(ctx, mailbox.ID, "Netflix", "auto"); err != nil {
|
||||||
|
t.Fatalf("reuse custom folder: %v", err)
|
||||||
|
}
|
||||||
|
var icon string
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT icon FROM folders WHERE mailbox_id=? AND name='Netflix'`, mailbox.ID).Scan(&icon); err != nil || icon != "heart" {
|
||||||
|
t.Fatalf("manual icon should be preserved icon=%q err=%v", icon, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailRuleApplyExistingWhenDisabledExcludesSent(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ts := httptest.NewServer(a.Router())
|
||||||
|
defer ts.Close()
|
||||||
|
admin := &testClient{t: t, server: ts}
|
||||||
|
|
||||||
|
var login map[string]any
|
||||||
|
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("admin login code=%d", code)
|
||||||
|
}
|
||||||
|
domainID := mustDefaultDomainID(t, a)
|
||||||
|
sender := createTestMailbox(t, admin, domainID, "rule-existing-sender", "Existing Sender", "Password123!", nil)
|
||||||
|
recipient := createTestMailbox(t, admin, domainID, "rule-existing-recipient", "Existing Recipient", "Password123!", nil)
|
||||||
|
subject := "same inbound and sent subject"
|
||||||
|
|
||||||
|
senderClient := &testClient{t: t, server: ts}
|
||||||
|
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("sender login=%d", code)
|
||||||
|
}
|
||||||
|
var incomingSend MailMessage
|
||||||
|
if code := senderClient.do("POST", "/api/mail/send", map[string]any{"to": []string{recipient.Address}, "subject": subject, "text": "incoming"}, &incomingSend); code != http.StatusCreated {
|
||||||
|
t.Fatalf("incoming send code=%d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rcpt := &testClient{t: t, server: ts}
|
||||||
|
if code := rcpt.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("recipient login=%d", code)
|
||||||
|
}
|
||||||
|
var outgoing MailMessage
|
||||||
|
if code := rcpt.do("POST", "/api/mail/send", map[string]any{"to": []string{sender.Address}, "subject": subject, "text": "outgoing"}, &outgoing); code != http.StatusCreated {
|
||||||
|
t.Fatalf("outgoing send code=%d", code)
|
||||||
|
}
|
||||||
|
var rule MailRule
|
||||||
|
if code := rcpt.do("POST", "/api/me/rules", map[string]any{
|
||||||
|
"mailboxId": recipient.ID,
|
||||||
|
"name": "existing disabled",
|
||||||
|
"conditions": []map[string]string{{"field": "subject", "operator": "equals", "value": subject}},
|
||||||
|
"actions": []map[string]string{{"type": "star"}},
|
||||||
|
"applyToExisting": true,
|
||||||
|
"enabled": false,
|
||||||
|
}, &rule); code != http.StatusCreated || rule.AppliedExistingCount != 1 || rule.Enabled {
|
||||||
|
t.Fatalf("create disabled existing rule code=%d rule=%+v", code, rule)
|
||||||
|
}
|
||||||
|
var inboundStarred, sentStarred int
|
||||||
|
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE mailbox_id=? AND subject=? AND folder_id IN (SELECT id FROM folders WHERE mailbox_id=? AND lower(name)='inbox')`, recipient.ID, subject, recipient.ID).Scan(&inboundStarred); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRow(`SELECT is_starred FROM messages WHERE id=?`, outgoing.ID).Scan(&sentStarred); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inboundStarred != 1 || sentStarred != 0 {
|
||||||
|
t.Fatalf("existing rule starred inbound=%d sent=%d", inboundStarred, sentStarred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleAttachmentConditionUsesFilenameOnly(t *testing.T) {
|
||||||
|
msg := ruleMessage{AttachmentNames: "notes.txt"}
|
||||||
|
if ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "contains", Value: "pdf"}, msg) {
|
||||||
|
t.Fatal("attachment condition must not match MIME type or unrelated extension")
|
||||||
|
}
|
||||||
|
if !ruleConditionMatches(MailRuleCondition{Field: "attachment", Operator: "ends-with", Value: ".txt"}, msg) {
|
||||||
|
t.Fatal("attachment condition should match filename")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMailRulesMailboxIsolation(t *testing.T) {
|
func TestMailRulesMailboxIsolation(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ts := httptest.NewServer(a.Router())
|
ts := httptest.NewServer(a.Router())
|
||||||
@@ -2293,7 +2478,7 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var custom MailFolder
|
var custom MailFolder
|
||||||
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" {
|
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档", "icon": "netflix"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" || custom.Icon != "netflix" {
|
||||||
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
|
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
|
||||||
}
|
}
|
||||||
var folders struct {
|
var folders struct {
|
||||||
@@ -2302,6 +2487,15 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
|||||||
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
|
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
|
||||||
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
|
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
|
||||||
}
|
}
|
||||||
|
foundIcon := ""
|
||||||
|
for _, folder := range folders.Items {
|
||||||
|
if folder.Name == "客户归档" {
|
||||||
|
foundIcon = folder.Icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundIcon != "netflix" {
|
||||||
|
t.Fatalf("folder icon=%q, want netflix", foundIcon)
|
||||||
|
}
|
||||||
|
|
||||||
var sent MailMessage
|
var sent MailMessage
|
||||||
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
|
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
|
||||||
@@ -2613,6 +2807,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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) migrateFolderIcons(ctx context.Context) error {
|
||||||
|
return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
@@ -98,12 +100,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
|
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
ORDER BY CASE
|
ORDER BY CASE
|
||||||
WHEN lower(f.name)='inbox' THEN 1000
|
WHEN lower(f.name)='inbox' THEN 1000
|
||||||
WHEN lower(f.name)='sent' THEN 5000
|
WHEN lower(f.name)='sent' THEN 5000
|
||||||
@@ -121,7 +123,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := []MailFolder{}
|
items := []MailFolder{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,7 +134,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
|
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
||||||
@@ -158,7 +160,7 @@ func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := []MailFolder{}
|
items := []MailFolder{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -266,6 +268,7 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
@@ -280,6 +283,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("system folder already exists"))
|
badRequest(w, errors.New("system folder already exists"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
icon := folderIconForName(name, req.Icon)
|
||||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
|
||||||
@@ -308,12 +312,12 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, mailboxID := range mailboxIDs {
|
for _, mailboxID := range mailboxIDs {
|
||||||
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil {
|
if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name, icon); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase})
|
respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), Icon: icon, SortOrder: customFolderDefaultSortOrderBase})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
@@ -321,7 +325,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
|
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||||
return
|
return
|
||||||
@@ -527,8 +531,88 @@ func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request,
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
|
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
|
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
|
||||||
return a.ensureFolder(ctx, mailboxID, name)
|
var existingID string
|
||||||
|
err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
|
||||||
|
if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
|
||||||
|
return existingID, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
id, err := a.ensureFolder(ctx, mailboxID, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderIconForName(name, requested string) string {
|
||||||
|
if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
|
||||||
|
return normalizeFolderIcon(icon)
|
||||||
|
}
|
||||||
|
value := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
for _, match := range []struct {
|
||||||
|
icon string
|
||||||
|
terms []string
|
||||||
|
}{
|
||||||
|
{"netflix", []string{"netflix", "奈飞", "网飞"}},
|
||||||
|
{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
|
||||||
|
{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
|
||||||
|
{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
|
||||||
|
{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
|
||||||
|
{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
|
||||||
|
{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
|
||||||
|
{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
|
||||||
|
{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
|
||||||
|
{"star", []string{"重要", "紧急", "important", "urgent"}},
|
||||||
|
{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
|
||||||
|
{"bell", []string{"提醒", "通知", "remind", "notification"}},
|
||||||
|
{"mail", []string{"邮件", "邮箱", "mail", "email"}},
|
||||||
|
} {
|
||||||
|
for _, term := range match.terms {
|
||||||
|
if folderNameContainsTerm(value, term) {
|
||||||
|
return match.icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderNameContainsTerm(value, term string) bool {
|
||||||
|
if term != "gpt" {
|
||||||
|
return strings.Contains(value, term)
|
||||||
|
}
|
||||||
|
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
|
||||||
|
}) {
|
||||||
|
if token == term {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFolderIcon(raw string) string {
|
||||||
|
icon := strings.TrimSpace(raw)
|
||||||
|
const customPrefix = "data:image/png;base64,"
|
||||||
|
if strings.HasPrefix(icon, customPrefix) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
|
||||||
|
config, configErr := png.DecodeConfig(bytes.NewReader(data))
|
||||||
|
validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
|
||||||
|
if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
|
icon = strings.ToLower(icon)
|
||||||
|
switch icon {
|
||||||
|
case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
|
||||||
|
return icon
|
||||||
|
default:
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
|
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
|
||||||
@@ -2232,14 +2316,14 @@ func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
||||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
|
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
|
||||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
COUNT(m.id) AS total,
|
||||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
||||||
var f MailFolder
|
var f MailFolder
|
||||||
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &f, nil
|
return &f, nil
|
||||||
|
|||||||
@@ -534,12 +534,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
appliedCount := int64(0)
|
appliedCount := int64(0)
|
||||||
if req.ApplyToExisting && enabled {
|
if req.ApplyToExisting {
|
||||||
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||||
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
||||||
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
||||||
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
||||||
item, err := scanRule(row)
|
item, err := scanRule(row)
|
||||||
@@ -1330,7 +1334,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
|||||||
if !ruleMatches(rule, msg) {
|
if !ruleMatches(rule, msg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if rule.StopProcessing {
|
if rule.StopProcessing {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1381,7 @@ type ruleMessage struct {
|
|||||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||||
var msg ruleMessage
|
var msg ruleMessage
|
||||||
var toAddrs, ccAddrs, receivedAt string
|
var toAddrs, ccAddrs, receivedAt string
|
||||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),from_addr,to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ruleMessage{}, false
|
return ruleMessage{}, false
|
||||||
@@ -1407,7 +1413,7 @@ func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string
|
|||||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
parts = append(parts, filename, contentType)
|
parts = append(parts, filename)
|
||||||
}
|
}
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
@@ -1453,15 +1459,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
|||||||
if operator == "" {
|
if operator == "" {
|
||||||
operator = "contains"
|
operator = "contains"
|
||||||
}
|
}
|
||||||
switch operator {
|
if !validRuleConditionOperator(field, operator) {
|
||||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
|
||||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
|
||||||
default:
|
|
||||||
return MailRuleCondition{}, false
|
return MailRuleCondition{}, false
|
||||||
}
|
}
|
||||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validRuleConditionOperator(field, operator string) bool {
|
||||||
|
switch field {
|
||||||
|
case "size":
|
||||||
|
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
|
||||||
|
case "date":
|
||||||
|
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
|
||||||
|
default:
|
||||||
|
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeRuleMatchMode(matchMode string) string {
|
func normalizeRuleMatchMode(matchMode string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||||
case "any", "or":
|
case "any", "or":
|
||||||
@@ -1710,23 +1724,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
|||||||
for _, action := range normalizeRuleActions(actions, "") {
|
for _, action := range normalizeRuleActions(actions, "") {
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case "archive":
|
case "archive":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "trash":
|
case "trash":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "move":
|
case "move":
|
||||||
target := ruleTargetFolder(action.Value)
|
target, err := normalizeFolderNameForUser(action.Value)
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
if err != nil {
|
||||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
var folderID string
|
||||||
|
if isSystemFolderName(target) {
|
||||||
|
folderID, err = a.ensureFolder(ctx, mailboxID, target)
|
||||||
|
} else {
|
||||||
|
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
case "star":
|
case "star":
|
||||||
starred := true
|
starred := true
|
||||||
@@ -1789,21 +1817,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func ruleTargetFolder(value string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "inbox":
|
|
||||||
return "Inbox"
|
|
||||||
case "archive":
|
|
||||||
return "Archive"
|
|
||||||
case "spam":
|
|
||||||
return "Spam"
|
|
||||||
case "trash":
|
|
||||||
return "Trash"
|
|
||||||
default:
|
|
||||||
return "Archive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
||||||
args := []any{userID}
|
args := []any{userID}
|
||||||
where := `mb.user_id=?`
|
where := `mb.user_id=?`
|
||||||
@@ -1811,7 +1824,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
|||||||
where += ` AND m.mailbox_id=?`
|
where += ` AND m.mailbox_id=?`
|
||||||
args = append(args, mailboxID)
|
args = append(args, mailboxID)
|
||||||
}
|
}
|
||||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id WHERE `+where+` AND lower(f.name) NOT IN ('sent','drafts')`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type MailFolder struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
TotalCount int `json:"totalCount"`
|
TotalCount int `json:"totalCount"`
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export function ConfirmDialog({
|
|||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 [&>button]:min-h-11 sm:[&>button]:min-h-9">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe
|
|||||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
|
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
|
||||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||||
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
export type MailFolder = { id: string; name: string; role: string; icon: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||||
export type MailMessage = {
|
export type MailMessage = {
|
||||||
|
|||||||
@@ -233,9 +233,9 @@ export const api = {
|
|||||||
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
|
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
|
||||||
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||||
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||||
createFolder: (payload: { mailboxId?: string; name: string }) => {
|
createFolder: (payload: { mailboxId?: string; name: string; icon?: string }) => {
|
||||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
|
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, icon: payload.icon }) })
|
||||||
},
|
},
|
||||||
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
|
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
|
||||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||||
|
|||||||
+161
-40
@@ -11,7 +11,7 @@ import TextAlign from "@tiptap/extension-text-align"
|
|||||||
import Placeholder from "@tiptap/extension-placeholder"
|
import Placeholder from "@tiptap/extension-placeholder"
|
||||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
|
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bell, Bold, Bot, Briefcase, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, GraduationCap, Heart, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, Mailbox as MailboxIcon, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plane, Plus, Quote, Receipt, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, ShoppingBag, Signature, SlidersHorizontal, Smile, Sparkles, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, Users, X } from "lucide-react"
|
||||||
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
@@ -49,6 +49,65 @@ import { useToast } from "@/hooks/use-toast"
|
|||||||
import { hasPermission } from "@/lib/permissions"
|
import { hasPermission } from "@/lib/permissions"
|
||||||
|
|
||||||
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Ban className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
|
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, drafts: <FileText className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, spam: <Ban className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
|
||||||
|
function NetflixFolderIcon({ className }: { className?: string }) {
|
||||||
|
return <span aria-hidden="true" className={cn("inline-flex items-center justify-center font-black text-red-600", className)}>N</span>
|
||||||
|
}
|
||||||
|
const customFolderIconOptions = [
|
||||||
|
{ key: "folder", label: "文件夹", icon: Folder },
|
||||||
|
{ key: "mail", label: "邮件", icon: Mail },
|
||||||
|
{ key: "briefcase", label: "工作", icon: Briefcase },
|
||||||
|
{ key: "users", label: "联系人", icon: Users },
|
||||||
|
{ key: "receipt", label: "账单", icon: Receipt },
|
||||||
|
{ key: "shopping", label: "购物", icon: ShoppingBag },
|
||||||
|
{ key: "plane", label: "旅行", icon: Plane },
|
||||||
|
{ key: "graduation", label: "学习", icon: GraduationCap },
|
||||||
|
{ key: "heart", label: "收藏", icon: Heart },
|
||||||
|
{ key: "star", label: "重要", icon: Star },
|
||||||
|
{ key: "bell", label: "提醒", icon: Bell },
|
||||||
|
{ key: "shield", label: "安全", icon: ShieldCheck },
|
||||||
|
{ key: "tag", label: "分类", icon: Tag },
|
||||||
|
{ key: "netflix", label: "Netflix", icon: NetflixFolderIcon },
|
||||||
|
{ key: "chatgpt", label: "ChatGPT", icon: Bot },
|
||||||
|
] as const
|
||||||
|
function suggestedFolderIcon(name: string) {
|
||||||
|
const value = name.trim().toLocaleLowerCase()
|
||||||
|
if (/netflix|奈飞|网飞/.test(value)) return "netflix"
|
||||||
|
if (/chatgpt|openai|\bgpt\b/.test(value)) return "chatgpt"
|
||||||
|
if (/账单|发票|收据|bill|invoice|receipt/.test(value)) return "receipt"
|
||||||
|
if (/购物|订单|快递|shop|order|delivery/.test(value)) return "shopping"
|
||||||
|
if (/旅行|旅游|机票|酒店|travel|trip|flight|hotel/.test(value)) return "plane"
|
||||||
|
if (/学习|教育|课程|学校|study|school|course/.test(value)) return "graduation"
|
||||||
|
if (/联系人|团队|用户|contact|team|people/.test(value)) return "users"
|
||||||
|
if (/工作|项目|客户|work|project|business|client/.test(value)) return "briefcase"
|
||||||
|
if (/收藏|喜欢|favorite|favourite/.test(value)) return "heart"
|
||||||
|
if (/重要|紧急|important|urgent/.test(value)) return "star"
|
||||||
|
if (/安全|验证|密码|登录|security|verify|password|login/.test(value)) return "shield"
|
||||||
|
if (/提醒|通知|remind|notification/.test(value)) return "bell"
|
||||||
|
if (/邮件|邮箱|mail|email/.test(value)) return "mail"
|
||||||
|
return "folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareFolderIcon(file: File) {
|
||||||
|
if (!/^image\/(png|jpe?g|webp)$/i.test(file.type)) throw new Error("仅支持 PNG、JPG 或 WebP 图片")
|
||||||
|
if (file.size > 2 * 1024 * 1024) throw new Error("原图不能超过 2 MB")
|
||||||
|
const bitmap = await createImageBitmap(file)
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
canvas.width = 64
|
||||||
|
canvas.height = 64
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) throw new Error("无法处理该图片")
|
||||||
|
const scale = Math.min(64 / bitmap.width, 64 / bitmap.height)
|
||||||
|
const width = Math.max(1, Math.round(bitmap.width * scale))
|
||||||
|
const height = Math.max(1, Math.round(bitmap.height * scale))
|
||||||
|
context.drawImage(bitmap, Math.round((64 - width) / 2), Math.round((64 - height) / 2), width, height)
|
||||||
|
const result = canvas.toDataURL("image/png")
|
||||||
|
if (result.length > 44_000) throw new Error("处理后的图标过大")
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
const folderLabels: Record<string, string> = {
|
const folderLabels: Record<string, string> = {
|
||||||
Inbox: "收件箱",
|
Inbox: "收件箱",
|
||||||
Sent: "已发送",
|
Sent: "已发送",
|
||||||
@@ -438,7 +497,7 @@ export function MailPage() {
|
|||||||
onSettled: () => setCancelingScheduledId(""),
|
onSettled: () => setCancelingScheduledId(""),
|
||||||
})
|
})
|
||||||
const createFolder = useMutation({
|
const createFolder = useMutation({
|
||||||
mutationFn: (name: string) => api.createFolder({ mailboxId: activeMailboxId, name }),
|
mutationFn: ({ name, icon }: { name: string; icon: string }) => api.createFolder({ mailboxId: activeMailboxId, name, icon }),
|
||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
qc.invalidateQueries({ queryKey: ["folders"] })
|
qc.invalidateQueries({ queryKey: ["folders"] })
|
||||||
setFolderDialogOpen(false)
|
setFolderDialogOpen(false)
|
||||||
@@ -902,6 +961,14 @@ export function MailPage() {
|
|||||||
function closeSidebarContextMenu() {
|
function closeSidebarContextMenu() {
|
||||||
setSidebarContextMenu(null)
|
setSidebarContextMenu(null)
|
||||||
}
|
}
|
||||||
|
function runAfterClosingMobileSidebar(action: () => void) {
|
||||||
|
if (!isMobile) {
|
||||||
|
action()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mobileSidebarOpen) setMobileSidebarOpen(false)
|
||||||
|
window.setTimeout(action, 250)
|
||||||
|
}
|
||||||
function activateSidebarItem(item: MailMenuItem) {
|
function activateSidebarItem(item: MailMenuItem) {
|
||||||
if (item.type === "starred") openStarred()
|
if (item.type === "starred") openStarred()
|
||||||
else if (item.type === "scheduled") openScheduled()
|
else if (item.type === "scheduled") openScheduled()
|
||||||
@@ -1373,7 +1440,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" aria-label="新建文件夹" title="新建文件夹" 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={() => runAfterClosingMobileSidebar(() => setFolderDialogOpen(true))}>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1494,6 +1561,34 @@ export function MailPage() {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>}
|
</SidebarGroup>}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarContextMenu
|
||||||
|
state={sidebarContextMenu}
|
||||||
|
canCreate={canManageFolders}
|
||||||
|
canReorder={canOrganizeCurrentMailbox}
|
||||||
|
canDelete={canManageFolders}
|
||||||
|
pending={reorderFolders.isPending || deleteFolder.isPending}
|
||||||
|
onClose={closeSidebarContextMenu}
|
||||||
|
onOpen={(item) => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
activateSidebarItem(item)
|
||||||
|
}}
|
||||||
|
onRefresh={() => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
void refreshMailData()
|
||||||
|
}}
|
||||||
|
onCreateFolder={() => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
runAfterClosingMobileSidebar(() => setFolderDialogOpen(true))
|
||||||
|
}}
|
||||||
|
onMove={(item, action) => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
moveSidebarFolder(item, action)
|
||||||
|
}}
|
||||||
|
onDelete={(item) => {
|
||||||
|
closeSidebarContextMenu()
|
||||||
|
runAfterClosingMobileSidebar(() => confirmDeleteFolder(item))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1812,39 +1907,11 @@ export function MailPage() {
|
|||||||
active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label })
|
active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SidebarContextMenu
|
|
||||||
state={sidebarContextMenu}
|
|
||||||
canCreate={canManageFolders}
|
|
||||||
canReorder={canOrganizeCurrentMailbox}
|
|
||||||
canDelete={canManageFolders}
|
|
||||||
pending={reorderFolders.isPending || deleteFolder.isPending}
|
|
||||||
onClose={closeSidebarContextMenu}
|
|
||||||
onOpen={(item) => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
activateSidebarItem(item)
|
|
||||||
}}
|
|
||||||
onRefresh={() => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
void refreshMailData()
|
|
||||||
}}
|
|
||||||
onCreateFolder={() => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
setFolderDialogOpen(true)
|
|
||||||
}}
|
|
||||||
onMove={(item, action) => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
moveSidebarFolder(item, action)
|
|
||||||
}}
|
|
||||||
onDelete={(item) => {
|
|
||||||
closeSidebarContextMenu()
|
|
||||||
confirmDeleteFolder(item)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CreateFolderDialog
|
<CreateFolderDialog
|
||||||
open={folderDialogOpen}
|
open={folderDialogOpen}
|
||||||
pending={createFolder.isPending}
|
pending={createFolder.isPending}
|
||||||
onOpenChange={setFolderDialogOpen}
|
onOpenChange={setFolderDialogOpen}
|
||||||
onCreate={(name) => createFolder.mutate(name)}
|
onCreate={(payload) => createFolder.mutate(payload)}
|
||||||
/>
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!pendingConfirm}
|
open={!!pendingConfirm}
|
||||||
@@ -1852,7 +1919,7 @@ export function MailPage() {
|
|||||||
description={pendingConfirm?.description}
|
description={pendingConfirm?.description}
|
||||||
confirmText={pendingConfirm?.confirmText || "确认"}
|
confirmText={pendingConfirm?.confirmText || "确认"}
|
||||||
destructive
|
destructive
|
||||||
pending={del.isPending || bulkPending}
|
pending={del.isPending || bulkPending || deleteFolder.isPending}
|
||||||
onOpenChange={(open) => { if (!open) setPendingConfirm(null) }}
|
onOpenChange={(open) => { if (!open) setPendingConfirm(null) }}
|
||||||
onConfirm={() => pendingConfirm?.onConfirm()}
|
onConfirm={() => pendingConfirm?.onConfirm()}
|
||||||
/>
|
/>
|
||||||
@@ -1862,7 +1929,7 @@ export function MailPage() {
|
|||||||
|
|
||||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean, includeUnknown: boolean): MailMenuItem[] {
|
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean, includeUnknown: boolean): MailMenuItem[] {
|
||||||
const byName = new Map(folders.map((item) => [item.name, item]))
|
const byName = new Map(folders.map((item) => [item.name, item]))
|
||||||
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 })
|
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), icon: "folder", sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 })
|
||||||
for (const item of folders) {
|
for (const item of folders) {
|
||||||
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
|
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
|
||||||
}
|
}
|
||||||
@@ -1872,7 +1939,7 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
|
|||||||
folderId: item.id,
|
folderId: item.id,
|
||||||
folderName: item.name,
|
folderName: item.name,
|
||||||
label: folderLabels[item.name] || item.name,
|
label: folderLabels[item.name] || item.name,
|
||||||
icon: isCustomMailFolder(item) ? <Folder className="h-4 w-4" /> : folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
icon: isCustomMailFolder(item) ? customFolderIcon(item.icon) : folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
||||||
count: item.name === "Drafts" ? item.totalCount : item.unreadCount,
|
count: item.name === "Drafts" ? item.totalCount : item.unreadCount,
|
||||||
custom: isCustomMailFolder(item),
|
custom: isCustomMailFolder(item),
|
||||||
order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name),
|
order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name),
|
||||||
@@ -1892,6 +1959,13 @@ function isCustomMailFolder(folder: Pick<MailFolder, "name" | "id">) {
|
|||||||
return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase())
|
return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customFolderIcon(iconKey: string | undefined, className = "h-4 w-4") {
|
||||||
|
if (iconKey?.startsWith("data:image/png;base64,")) return <img src={iconKey} alt="" className={cn("shrink-0 object-contain", className)} />
|
||||||
|
const option = customFolderIconOptions.find((item) => item.key === iconKey) || customFolderIconOptions[0]
|
||||||
|
const Icon = option.icon
|
||||||
|
return <Icon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
function compareMailFolders(a: MailFolder, b: MailFolder) {
|
function compareMailFolders(a: MailFolder, b: MailFolder) {
|
||||||
return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name)
|
return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name)
|
||||||
}
|
}
|
||||||
@@ -2513,7 +2587,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction =
|
|||||||
<DropdownMenuContent align="end" className="w-44">
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
{movableFolders.map((folder) => (
|
{movableFolders.map((folder) => (
|
||||||
<DropdownMenuItem key={folder.id} onSelect={() => onMoveToFolder(folder.name)}>
|
<DropdownMenuItem key={folder.id} onSelect={() => onMoveToFolder(folder.name)}>
|
||||||
{folderIcons[folder.role] || <Folder className="h-4 w-4" />}
|
{isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || <Folder className="h-4 w-4" />}
|
||||||
<span className="min-w-0 flex-1 truncate">{folderLabels[folder.name] || folder.name}</span>
|
<span className="min-w-0 flex-1 truncate">{folderLabels[folder.name] || folder.name}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -2673,7 +2747,7 @@ function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canM
|
|||||||
<div className="max-h-44 overflow-y-auto">
|
<div className="max-h-44 overflow-y-auto">
|
||||||
{movableFolders.map((folder) => (
|
{movableFolders.map((folder) => (
|
||||||
<Button key={folder.id} type="button" variant="ghost" className={itemClass} onClick={() => moveToFolder(folder.name)}>
|
<Button key={folder.id} type="button" variant="ghost" className={itemClass} onClick={() => moveToFolder(folder.name)}>
|
||||||
{folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
|
{isCustomMailFolder(folder) ? customFolderIcon(folder.icon) : folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
|
||||||
<span className="min-w-0 flex-1 truncate text-left">{folderLabels[folder.name] || folder.name}</span>
|
<span className="min-w-0 flex-1 truncate text-left">{folderLabels[folder.name] || folder.name}</span>
|
||||||
{folder.totalCount > 0 && <span className="text-xs text-muted-foreground">{folder.totalCount}</span>}
|
{folder.totalCount > 0 && <span className="text-xs text-muted-foreground">{folder.totalCount}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2718,12 +2792,21 @@ function contextMenuPosition(x: number, y: number) {
|
|||||||
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
|
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (name: string) => void }) {
|
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
|
||||||
const [name, setName] = React.useState("")
|
const [name, setName] = React.useState("")
|
||||||
|
const [icon, setIcon] = React.useState("auto")
|
||||||
|
const [uploadError, setUploadError] = React.useState("")
|
||||||
|
const iconInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) setName("")
|
if (open) {
|
||||||
|
setName("")
|
||||||
|
setIcon("auto")
|
||||||
|
setUploadError("")
|
||||||
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
|
const suggestedIcon = suggestedFolderIcon(trimmed)
|
||||||
|
const resolvedIcon = icon === "auto" ? suggestedIcon : icon
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[min(92vw,28rem)] max-w-none">
|
<DialogContent className="w-[min(92vw,28rem)] max-w-none">
|
||||||
@@ -2734,13 +2817,51 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (trimmed) onCreate(trimmed)
|
if (trimmed) onCreate({ name: trimmed, icon: resolvedIcon })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-folder-name">文件夹名称</Label>
|
<Label htmlFor="new-folder-name">文件夹名称</Label>
|
||||||
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
|
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset className="space-y-2">
|
||||||
|
<legend className="text-sm font-medium">图标</legend>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
<Button type="button" variant="outline" size="icon" className={cn("relative size-10 shadow-none", icon === "auto" && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => setIcon("auto")} aria-label={`自动匹配:${customFolderIconOptions.find((item) => item.key === suggestedIcon)?.label || "文件夹"}`} title={`自动匹配:${customFolderIconOptions.find((item) => item.key === suggestedIcon)?.label || "文件夹"}`} aria-pressed={icon === "auto"}>
|
||||||
|
{customFolderIcon(suggestedIcon)}
|
||||||
|
<Sparkles className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-background" />
|
||||||
|
</Button>
|
||||||
|
{customFolderIconOptions.map((option) => {
|
||||||
|
const Icon = option.icon
|
||||||
|
return (
|
||||||
|
<Button key={option.key} type="button" variant="outline" size="icon" className={cn("size-10 shadow-none", icon === option.key && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => setIcon(option.key)} aria-label={option.label} title={option.label} aria-pressed={icon === option.key}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Button type="button" variant="outline" size="icon" className={cn("size-10 shadow-none", icon.startsWith("data:image/png;base64,") && "border-primary bg-primary/10 text-primary ring-1 ring-primary")} onClick={() => iconInputRef.current?.click()} aria-label="上传自定义图标" title="上传自定义图标" aria-pressed={icon.startsWith("data:image/png;base64,")}>
|
||||||
|
{icon.startsWith("data:image/png;base64,") ? customFolderIcon(icon) : <Upload className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={iconInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = ""
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
setUploadError("")
|
||||||
|
setIcon(await prepareFolderIcon(file))
|
||||||
|
} catch (error) {
|
||||||
|
setUploadError(error instanceof Error ? error.message : "无法处理该图片")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{uploadError && <p role="alert" className="text-xs text-destructive">{uploadError}</p>}
|
||||||
|
</fieldset>
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
<Button disabled={!trimmed || pending}>{pending ? "创建中..." : "创建"}</Button>
|
<Button disabled={!trimmed || pending}>{pending ? "创建中..." : "创建"}</Button>
|
||||||
|
|||||||
+118
-77
@@ -1130,10 +1130,21 @@ function MailboxManagement({
|
|||||||
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 = React.useMemo(() => [...(forwarding.data?.verifiedEmails || [])].sort((a, b) => forwardingTargetCollator.compare(a.email, b.email)), [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(() => sortForwardingTargets(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[]> = {}
|
||||||
@@ -1350,9 +1361,8 @@ 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>
|
||||||
@@ -1362,20 +1372,14 @@ function MailboxManagement({
|
|||||||
<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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground">暂未添加验证邮箱,请先点击「管理验证邮箱」添加。</p>}
|
|
||||||
<p className="mt-3 text-sm text-muted-foreground">提示:点击邮箱列表中的「转发」按钮,可在账号级目标之外追加该邮箱自己的转发目标。</p>
|
<p className="mt-3 text-sm text-muted-foreground">提示:点击邮箱列表中的「转发」按钮,可在账号级目标之外追加该邮箱自己的转发目标。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1469,45 +1473,58 @@ function MailboxManagement({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
|
<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>
|
||||||
@@ -1852,35 +1869,55 @@ function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedL
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VerifiedEmailRow({ item, busy, onResend, onRemove }: {
|
||||||
|
item: ForwardingVerifiedEmail
|
||||||
|
busy: boolean
|
||||||
|
onResend: (item: ForwardingVerifiedEmail) => void
|
||||||
|
onRemove: (id: string, email: string) => void
|
||||||
|
}) {
|
||||||
|
const tone = forwardingEmailTone(item)
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[72px] gap-3 border-b px-4 py-3 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("size-2 shrink-0 rounded-full", tone.dotClass)} />
|
||||||
|
<span className="min-w-0 truncate text-sm font-semibold" title={item.email}>{item.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn("mt-1 pl-4 text-xs leading-5", item.verified ? "text-muted-foreground" : tone.detailClass)}>
|
||||||
|
{item.verified ? `验证于 ${formatDateTime(item.verifiedAt || item.createdAt)}` : forwardingEmailStatusText(item)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||||
|
{!item.verified && (
|
||||||
|
<Button type="button" variant="outline" size="sm" className="h-9" disabled={busy} onClick={() => onResend(item)}>重发</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-9 text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={busy} onClick={() => onRemove(item.id, item.email)} aria-label={`删除 ${item.email}`}>删除</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function forwardingEmailTone(item: ForwardingVerifiedEmail) {
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2236,7 +2273,7 @@ function RulesSection({ items, mailboxes, labels, verifiedEmails, open, onOpenCh
|
|||||||
<Button className="h-9 w-full rounded-md px-4 text-sm font-normal sm:w-auto" onClick={() => { setEditingRule(null); onOpenChange(true) }}>新建规则</Button>
|
<Button className="h-9 w-full rounded-md px-4 text-sm font-normal sm:w-auto" onClick={() => { setEditingRule(null); onOpenChange(true) }}>新建规则</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
|
{items.map((item, index) => <RuleListItem key={item.id} item={item} index={index} count={items.length} mailboxLabel={item.mailboxId ? mailboxes.find((mailbox) => mailbox.id === item.mailboxId)?.address || "指定邮箱" : "全部邮箱"} pending={pending} onEdit={() => { setEditingRule(item); onOpenChange(true) }} onToggle={() => onToggle(item)} onMove={(direction) => onMove(item.id, direction)} onApply={() => onApply(item.id)} onDelete={onDelete} />)}
|
||||||
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
|
{items.length === 0 && <EmptyState icon={<SlidersHorizontal />} text="暂无收件规则" description="新建规则后,可自动标记、移动或转发符合条件的邮件。" className="min-h-[180px] border-solid bg-card" />}
|
||||||
</div>
|
</div>
|
||||||
<RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
|
<RuleDialog open={open} onOpenChange={setDialogOpen} mailboxes={mailboxes} labels={labels} verifiedEmails={verifiedEmails} pending={pending} initialRule={editingRule} onSave={(payload) => editingRule ? onUpdate(editingRule.id, payload) : onCreate(payload)} />
|
||||||
@@ -2299,13 +2336,13 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex h-svh w-screen max-w-none gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)]">
|
<DialogContent className="!flex h-dvh w-screen max-w-none flex-col gap-0 overflow-hidden border-0 p-0 sm:h-auto sm:max-h-[92vh] sm:w-[min(94vw,56rem)] sm:border">
|
||||||
<DialogHeader className="border-b px-4 py-4 text-left sm:px-8 sm:py-6">
|
<DialogHeader className="shrink-0 border-b px-4 py-4 pr-16 text-left sm:px-8 sm:py-6 sm:pr-16">
|
||||||
<DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle>
|
<DialogTitle className="text-xl sm:text-2xl">{initialRule ? "编辑规则" : "新建规则"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
|
<form className="flex min-h-0 flex-1 flex-col" onSubmit={submit}>
|
||||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
|
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-4 py-5 sm:space-y-7 sm:px-8 sm:py-7">
|
||||||
<Field label="名称"><Input value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
|
<Field label="名称"><Input className="h-11 sm:h-9" value={name} onChange={(event) => setName(event.target.value)} placeholder="我的规则" /></Field>
|
||||||
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
|
<Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} /></Field>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -2318,18 +2355,20 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{conditions.map((condition, index) => (
|
{conditions.map((condition, index) => (
|
||||||
<div key={index} className="grid gap-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto]">
|
<div key={index} className="grid grid-cols-2 gap-2 rounded-md border p-3 md:grid-cols-[180px_128px_minmax(0,1fr)_auto_auto] md:gap-3 md:border-0 md:p-0">
|
||||||
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||||
<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 className="col-span-2 h-11 md:col-span-1 md:h-9" type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||||
<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>
|
<div className="col-span-2 flex justify-end gap-2 md:contents">
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-11 text-muted-foreground md:size-9" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="outline" size="icon" className="size-11 md:size-9" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2339,14 +2378,16 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
<div className="text-sm">执行以下动作</div>
|
<div className="text-sm">执行以下动作</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{actions.map((action, index) => (
|
{actions.map((action, index) => (
|
||||||
<div key={index} className={cn("grid gap-3", action.type === "forward" ? "md:grid-cols-[180px_minmax(0,1fr)_auto]" : "md:grid-cols-[180px_minmax(0,1fr)_auto_auto]")}>
|
<div key={index} className="grid gap-2 rounded-md border p-3 md:grid-cols-[180px_minmax(0,1fr)_auto_auto] md:gap-3 md:border-0 md:p-0">
|
||||||
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
|
<Select value={action.type} onValueChange={(value) => updateAction(index, { type: value as MailRuleAction["type"], value: "", labelId: "" })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-11 md:h-9"><SelectValue /></SelectTrigger>
|
||||||
<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)} />
|
<div className="min-w-0"><RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} /></div>
|
||||||
<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>
|
<div className="flex justify-end gap-2 md:contents">
|
||||||
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
|
<Button type="button" variant="ghost" size="icon" className="size-11 text-muted-foreground md:size-9" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="outline" size="icon" className="size-11 md:size-9" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2357,13 +2398,13 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
|
<RuleCheckbox checked={enabled} onCheckedChange={setEnabled} label="立即启用" />
|
||||||
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
|
<RuleCheckbox checked={applyToExisting} onCheckedChange={setApplyToExisting} label="应用于现有邮件" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
|
<RuleCheckbox checked={stopProcessing} onCheckedChange={setStopProcessing} label="终止规则:命中此规则后不再应用其他规则" />
|
||||||
<Info className="h-4 w-4 text-muted-foreground" />
|
<Info className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 border-t px-4 py-4 sm:px-8 sm:py-5 [&>button]:w-full sm:[&>button]:w-auto">
|
<DialogFooter className="shrink-0 gap-2 border-t px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 sm:px-8 sm:py-5 [&>button]:min-h-11 [&>button]:w-full sm:[&>button]:min-h-9 sm:[&>button]:w-auto">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
<Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
|
<Button disabled={!canCreate}>{pending ? "保存中..." : initialRule ? "保存修改" : "创建"}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -2421,15 +2462,13 @@ function verifiedRuleForwardTargets(value: string, verifiedEmails: string[]) {
|
|||||||
|
|
||||||
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
|
function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string }) {
|
||||||
const id = React.useId()
|
const id = React.useId()
|
||||||
return <div className="flex items-center gap-3"><Checkbox id={id} checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-base font-medium">{label}</Label></div>
|
return <div className="flex min-w-0 items-start gap-3"><Checkbox id={id} className="mt-0.5" checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-sm font-medium leading-5 sm:text-base sm:leading-6">{label}</Label></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
|
function RuleListItem({ item, index, count, mailboxLabel, pending, onEdit, onToggle, onMove, onApply, onDelete }: { item: MailRule; index: number; count: number; mailboxLabel: string; pending: boolean; onEdit: () => void; onToggle: () => void; onMove: (direction: "up" | "down") => void; onApply: () => void; onDelete: (id: string) => void }) {
|
||||||
const [confirmOpen, setConfirmOpen] = React.useState(false)
|
const [confirmOpen, setConfirmOpen] = React.useState(false)
|
||||||
const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
|
const conditionText = ruleConditionSummary(item.conditions, item.fromContains, item.subjectContains)
|
||||||
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作"
|
const actionText = item.actions.map(ruleActionSummary).filter(Boolean).join(";") || "无动作"
|
||||||
const moveDirection = index === 0 ? "down" : "up"
|
|
||||||
const canMove = count > 1
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-[110px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20">
|
<div className="grid min-h-[110px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-card px-4 py-4 transition-colors hover:bg-muted/20">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
@@ -2438,12 +2477,14 @@ function RuleListItem({ item, index, count, pending, onEdit, onToggle, onMove, o
|
|||||||
<span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
|
<span className={cn("shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium", item.enabled ? "text-emerald-700" : "bg-muted text-muted-foreground")}>{item.enabled ? "已启用" : "已停用"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid text-sm leading-6 text-muted-foreground">
|
<div className="grid text-sm leading-6 text-muted-foreground">
|
||||||
|
<p className="truncate"><span className="text-muted-foreground">适用:</span> {mailboxLabel}</p>
|
||||||
<p className="truncate"><span className="text-muted-foreground">条件:</span> {conditionText}</p>
|
<p className="truncate"><span className="text-muted-foreground">条件:</span> {conditionText}</p>
|
||||||
<p className="truncate"><span className="text-muted-foreground">动作:</span> {actionText}</p>
|
<p className="truncate"><span className="text-muted-foreground">动作:</span> {actionText}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || !canMove} onClick={() => onMove(moveDirection)} aria-label={moveDirection === "up" ? "上移" : "下移"} title={moveDirection === "up" ? "上移" : "下移"}>{moveDirection === "up" ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</Button>
|
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || index === 0} onClick={() => onMove("up")} aria-label="上移" title="上移"><ChevronUp className="h-4 w-4" /></Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending || index === count - 1} onClick={() => onMove("down")} aria-label="下移" title="下移"><ChevronDown className="h-4 w-4" /></Button>
|
||||||
<span className="mx-1 h-6 w-px bg-border" />
|
<span className="mx-1 h-6 w-px bg-border" />
|
||||||
<Button type="button" variant="ghost" size="icon" className={cn("size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button>
|
<Button type="button" variant="ghost" size="icon" className={cn("size-7", item.enabled ? "text-emerald-600" : "text-muted-foreground")} disabled={pending} onClick={onToggle} aria-label={item.enabled ? "禁用规则" : "启用规则"} title={item.enabled ? "禁用规则" : "启用规则"}>{item.enabled ? <Bell className="h-4 w-4" /> : <BellOff className="h-4 w-4" />}</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button>
|
<Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" disabled={pending} onClick={onEdit} aria-label="编辑规则" title="编辑规则"><PencilLine className="h-4 w-4" /></Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user