fix(maildir): 同步消息标记到 Maildir
- 标记已读、加星时同步重写 `raw_path`,保持数据库与文件系统状态一致。 - 新增 Maildir 标记路径计算与迁移逻辑,支持读/星标变化后调整目录和文件名。 - 补充测试覆盖已读、未读、加星场景的 Maildir 路径变化。
This commit is contained in:
@@ -2580,6 +2580,52 @@ func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageFlagsUpdateMaildir(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
a.cfg.MaildirRoot = t.TempDir()
|
||||
srv := httptest.NewServer(a.Router())
|
||||
defer srv.Close()
|
||||
client := &testClient{t: t, server: srv}
|
||||
var login map[string]any
|
||||
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||
clearMailboxMessagesForTest(t, a, mb.ID)
|
||||
|
||||
msg, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
||||
MailboxID: mb.ID,
|
||||
To: []string{"admin@lanqin.local"},
|
||||
Subject: "flag me",
|
||||
Text: "flag body",
|
||||
HTML: "<p>flag body</p>",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := client.do("POST", "/api/mail/messages/"+msg.ID+"/mark-read", map[string]bool{"read": false}, nil); code != http.StatusOK {
|
||||
t.Fatalf("mark unread code=%d", code)
|
||||
}
|
||||
unreadPath := maildirRawPathForTest(t, a, msg.ID)
|
||||
if strings.Contains(filepath.Base(unreadPath), maildirFlagSeparator()) {
|
||||
t.Fatalf("unread path should not have seen flag: %s", unreadPath)
|
||||
}
|
||||
if !strings.EqualFold(filepath.Base(filepath.Dir(unreadPath)), "new") {
|
||||
t.Fatalf("unread path dir=%s, want new", filepath.Dir(unreadPath))
|
||||
}
|
||||
if code := client.do("POST", "/api/mail/messages/"+msg.ID+"/star", map[string]bool{"starred": true}, nil); code != http.StatusOK {
|
||||
t.Fatalf("star code=%d", code)
|
||||
}
|
||||
starredPath := maildirRawPathForTest(t, a, msg.ID)
|
||||
if !strings.Contains(filepath.Base(starredPath), maildirFlagSeparator()+"F") {
|
||||
t.Fatalf("starred path missing F flag: %s", starredPath)
|
||||
}
|
||||
if !strings.EqualFold(filepath.Base(filepath.Dir(starredPath)), "cur") {
|
||||
t.Fatalf("starred path dir=%s, want cur", filepath.Dir(starredPath))
|
||||
}
|
||||
}
|
||||
|
||||
func mustDefaultDomainID(t *testing.T, a *App) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
|
||||
@@ -1138,6 +1138,10 @@ func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Read != nil {
|
||||
read = *req.Read
|
||||
}
|
||||
if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
return
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, updated_at=? WHERE id=?`, boolInt(read), a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
@@ -1160,6 +1164,10 @@ func (a *App) handleStar(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Starred != nil {
|
||||
starred = *req.Starred
|
||||
}
|
||||
if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, nil, &starred); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
return
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, updated_at=? WHERE id=?`, boolInt(starred), a.now().UTC().Format(time.RFC3339Nano), msg.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update message")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -67,6 +68,14 @@ func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -74,6 +83,9 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if folderID != "" {
|
||||
state.FolderID = folderID
|
||||
}
|
||||
if state.MailboxID == "" || state.FolderID == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -118,7 +130,11 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
|
||||
if replace || state.RawPath != "" {
|
||||
a.removeMaildirPath(ctx, state.RawPath)
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?, updated_at=? WHERE id=?`, finalPath, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
if updateFolder {
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
} else {
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?, updated_at=? WHERE id=?`, finalPath, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -204,11 +220,7 @@ func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, fol
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
|
||||
}
|
||||
|
||||
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
||||
@@ -219,6 +231,52 @@ func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.RawPath == "" {
|
||||
return nil
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
currentRead := state.IsRead
|
||||
currentStarred := state.IsStarred
|
||||
if read != nil {
|
||||
currentRead = *read
|
||||
}
|
||||
if starred != nil {
|
||||
currentStarred = *starred
|
||||
}
|
||||
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
|
||||
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE id=?`, targetPath, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
|
||||
rawPath = strings.TrimSpace(rawPath)
|
||||
if rawPath == "" {
|
||||
@@ -282,19 +340,21 @@ type maildirMessageState struct {
|
||||
MessageID string
|
||||
RawPath string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
}
|
||||
|
||||
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
|
||||
var state maildirMessageState
|
||||
var mailboxID, folderID sql.NullString
|
||||
var read int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read)
|
||||
var read, starred int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.MailboxID = mailboxID.String
|
||||
state.FolderID = folderID.String
|
||||
state.IsRead = intBool(read)
|
||||
state.IsStarred = intBool(starred)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -419,3 +479,36 @@ func messageDate(msg storedMessage) time.Time {
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func maildirPathWithFlags(path string, read, starred bool) string {
|
||||
dir := filepath.Dir(path)
|
||||
name := filepath.Base(path)
|
||||
if read || starred {
|
||||
dir = filepath.Join(filepath.Dir(dir), "cur")
|
||||
} else if filepath.Base(dir) == "cur" {
|
||||
dir = filepath.Join(filepath.Dir(dir), "new")
|
||||
}
|
||||
base := name
|
||||
sep := maildirFlagSeparator()
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
base = base[:idx]
|
||||
}
|
||||
flags := ""
|
||||
if read {
|
||||
flags += "S"
|
||||
}
|
||||
if starred {
|
||||
flags += "F"
|
||||
}
|
||||
if flags != "" {
|
||||
base += sep + flags
|
||||
}
|
||||
return filepath.Join(dir, base)
|
||||
}
|
||||
|
||||
func maildirFlagSeparator() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "!2,"
|
||||
}
|
||||
return ":2,"
|
||||
}
|
||||
|
||||
@@ -1087,10 +1087,18 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
||||
}
|
||||
}
|
||||
case "star":
|
||||
starred := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "mark-read":
|
||||
read := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, &read, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user