release: prepare v1.2.25
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
- 修复收信规则移动到自定义文件夹时被错误归入“已归档”的问题,现在会按规则名称真实创建目标文件夹。
|
||||||
|
- 修正发件人、附件名、邮件大小和日期条件的匹配边界,拒绝字段不支持的运算符,避免规则保存后永远无法命中。
|
||||||
|
- “应用到现有邮件”不再处理已发送和草稿邮件,规则暂停启用时也可执行用户明确选择的现有邮件处理。
|
||||||
|
- 完善规则动作失败处理:失败的动作不再误中止后续规则,归档、删除和移动错误不再被静默忽略。
|
||||||
|
- 收信规则列表新增适用邮箱显示,并提供独立的上移、下移按钮,多条规则时可完整调整优先级。
|
||||||
|
- 新增自定义文件夹图标,支持按名称自动匹配、手动选择以及上传小图标,并内置 Netflix、ChatGPT、账单、购物、旅行、工作等常用类型。
|
||||||
|
- 上传图标会在浏览器本地缩放为 64×64 PNG,服务端校验 PNG 文件头并限制在 32 KB;不联网查询品牌,不保留上传原图。
|
||||||
|
- 文件夹图标已在侧栏、桌面端与移动端移动菜单中统一显示,数据库升级会自动为旧文件夹补充默认图标。
|
||||||
|
- 补充收信规则、自定义文件夹、图标自动匹配、手动图标保留和上传格式安全边界的回归测试。
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)}` : ""
|
||||||
|
|||||||
+123
-10
@@ -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)
|
||||||
@@ -1844,7 +1903,7 @@ export function MailPage() {
|
|||||||
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}
|
||||||
@@ -1862,7 +1921,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 +1931,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 +1951,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 +2579,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 +2739,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 +2784,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 +2809,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>
|
||||||
|
|||||||
@@ -2273,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)} />
|
||||||
@@ -2461,12 +2461,10 @@ function RuleCheckbox({ checked, onCheckedChange, label }: { checked: boolean; o
|
|||||||
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 items-center gap-3"><Checkbox id={id} checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} /><Label htmlFor={id} className="text-base font-medium">{label}</Label></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleListItem({ item, index, count, 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">
|
||||||
@@ -2475,12 +2473,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