fix(maildir): 同步消息标记到 Maildir

- 标记已读、加星时同步重写 `raw_path`,保持数据库与文件系统状态一致。
- 新增 Maildir 标记路径计算与迁移逻辑,支持读/星标变化后调整目录和文件名。
- 补充测试覆盖已读、未读、加星场景的 Maildir 路径变化。
This commit is contained in:
LanQin_
2026-06-24 15:27:30 +08:00
parent 1788d49a59
commit f8d058f7e4
4 changed files with 163 additions and 8 deletions
+46
View File
@@ -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
+8
View File
@@ -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")
+100 -7
View File
@@ -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)
}
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
}