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:
@@ -317,6 +317,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT 'folder',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -690,6 +691,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateFolderIcons(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateExternalIMAP(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1050,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) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -2293,7 +2478,7 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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, "客户归档") {
|
||||
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
|
||||
}
|
||||
foundIcon := ""
|
||||
for _, folder := range folders.Items {
|
||||
if folder.Name == "客户归档" {
|
||||
foundIcon = folder.Icon
|
||||
}
|
||||
}
|
||||
if foundIcon != "netflix" {
|
||||
t.Fatalf("folder icon=%q, want netflix", foundIcon)
|
||||
}
|
||||
|
||||
var sent MailMessage
|
||||
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
|
||||
|
||||
@@ -76,6 +76,10 @@ func (a *App) migrateFolderSortOrder(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateFolderIcons(ctx context.Context) error {
|
||||
return a.ensureTableColumn(ctx, "folders", "icon", `ALTER TABLE folders ADD COLUMN icon TEXT NOT NULL DEFAULT 'folder'`)
|
||||
}
|
||||
|
||||
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
@@ -98,12 +100,12 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
|
||||
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,
|
||||
COUNT(m.id) AS total,
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.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
|
||||
WHEN lower(f.name)='inbox' THEN 1000
|
||||
WHEN lower(f.name)='sent' THEN 5000
|
||||
@@ -121,7 +123,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.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")
|
||||
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) {
|
||||
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,
|
||||
COUNT(m.id) AS total,
|
||||
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{}
|
||||
for rows.Next() {
|
||||
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")
|
||||
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) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
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"))
|
||||
return
|
||||
}
|
||||
icon := folderIconForName(name, req.Icon)
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
|
||||
@@ -308,12 +312,12 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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")
|
||||
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
|
||||
}
|
||||
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")
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
|
||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||
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})
|
||||
}
|
||||
|
||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
|
||||
return a.ensureFolder(ctx, mailboxID, name)
|
||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
|
||||
var existingID string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
|
||||
if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
|
||||
return existingID, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
id, err := a.ensureFolder(ctx, mailboxID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func folderIconForName(name, requested string) string {
|
||||
if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
|
||||
return normalizeFolderIcon(icon)
|
||||
}
|
||||
value := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, match := range []struct {
|
||||
icon string
|
||||
terms []string
|
||||
}{
|
||||
{"netflix", []string{"netflix", "奈飞", "网飞"}},
|
||||
{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
|
||||
{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
|
||||
{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
|
||||
{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
|
||||
{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
|
||||
{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
|
||||
{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
|
||||
{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
|
||||
{"star", []string{"重要", "紧急", "important", "urgent"}},
|
||||
{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
|
||||
{"bell", []string{"提醒", "通知", "remind", "notification"}},
|
||||
{"mail", []string{"邮件", "邮箱", "mail", "email"}},
|
||||
} {
|
||||
for _, term := range match.terms {
|
||||
if folderNameContainsTerm(value, term) {
|
||||
return match.icon
|
||||
}
|
||||
}
|
||||
}
|
||||
return "folder"
|
||||
}
|
||||
|
||||
func folderNameContainsTerm(value, term string) bool {
|
||||
if term != "gpt" {
|
||||
return strings.Contains(value, term)
|
||||
}
|
||||
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
|
||||
}) {
|
||||
if token == term {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeFolderIcon(raw string) string {
|
||||
icon := strings.TrimSpace(raw)
|
||||
const customPrefix = "data:image/png;base64,"
|
||||
if strings.HasPrefix(icon, customPrefix) {
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
|
||||
config, configErr := png.DecodeConfig(bytes.NewReader(data))
|
||||
validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
|
||||
if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
|
||||
return icon
|
||||
}
|
||||
return "folder"
|
||||
}
|
||||
icon = strings.ToLower(icon)
|
||||
switch icon {
|
||||
case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
|
||||
return icon
|
||||
default:
|
||||
return "folder"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) 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) {
|
||||
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,
|
||||
COUNT(m.id) AS total,
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.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
|
||||
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 &f, nil
|
||||
|
||||
@@ -534,12 +534,16 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
appliedCount := int64(0)
|
||||
if req.ApplyToExisting && enabled {
|
||||
appliedCount, _ = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||
if req.ApplyToExisting {
|
||||
appliedCount, err = a.applyRuleToExistingMessages(r.Context(), user.ID, mailboxID, MailRule{
|
||||
ID: id, UserID: user.ID, MailboxID: mailboxID, Name: name, MatchMode: matchMode,
|
||||
Conditions: conditions, Actions: actions, ApplyToExisting: req.ApplyToExisting, StopProcessing: req.StopProcessing,
|
||||
FromContains: fromContains, SubjectContains: subjectContains, Action: action, Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "rule saved but failed to apply to existing messages")
|
||||
return
|
||||
}
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=?`, id)
|
||||
item, err := scanRule(row)
|
||||
@@ -1330,7 +1334,9 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||
if err := a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions); err != nil {
|
||||
continue
|
||||
}
|
||||
if rule.StopProcessing {
|
||||
break
|
||||
}
|
||||
@@ -1375,7 +1381,7 @@ type ruleMessage struct {
|
||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||
var msg ruleMessage
|
||||
var toAddrs, ccAddrs, receivedAt string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
parts = append(parts, filename, contentType)
|
||||
parts = append(parts, filename)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -1453,15 +1459,23 @@ func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
switch operator {
|
||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||
default:
|
||||
if !validRuleConditionOperator(field, operator) {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||
}
|
||||
|
||||
func validRuleConditionOperator(field, operator string) bool {
|
||||
switch field {
|
||||
case "size":
|
||||
return operator == "gt" || operator == "gte" || operator == "lt" || operator == "lte" || operator == "equals" || operator == "not-equals"
|
||||
case "date":
|
||||
return operator == "before" || operator == "after" || operator == "on" || operator == "equals" || operator == "not-equals"
|
||||
default:
|
||||
return operator == "contains" || operator == "not-contains" || operator == "equals" || operator == "not-equals" || operator == "starts-with" || operator == "ends-with"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRuleMatchMode(matchMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||
case "any", "or":
|
||||
@@ -1710,23 +1724,37 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
||||
for _, action := range normalizeRuleActions(actions, "") {
|
||||
switch action.Type {
|
||||
case "archive":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Archive")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "trash":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Trash")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "move":
|
||||
target := ruleTargetFolder(action.Value)
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := normalizeFolderNameForUser(action.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderID string
|
||||
if isSystemFolderName(target) {
|
||||
folderID, err = a.ensureFolder(ctx, mailboxID, target)
|
||||
} else {
|
||||
folderID, err = a.ensureCustomFolder(ctx, mailboxID, target, "auto")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "star":
|
||||
starred := true
|
||||
@@ -1789,21 +1817,6 @@ func (a *App) applyRuleLabel(ctx context.Context, mailboxID, messageID string, a
|
||||
return err
|
||||
}
|
||||
|
||||
func ruleTargetFolder(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "inbox":
|
||||
return "Inbox"
|
||||
case "archive":
|
||||
return "Archive"
|
||||
case "spam":
|
||||
return "Spam"
|
||||
case "trash":
|
||||
return "Trash"
|
||||
default:
|
||||
return "Archive"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID string, rule MailRule) (int64, error) {
|
||||
args := []any{userID}
|
||||
where := `mb.user_id=?`
|
||||
@@ -1811,7 +1824,7 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
where += ` AND m.mailbox_id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id 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 {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
|
||||
Reference in New Issue
Block a user