diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 26e4430..40fc6be 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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: "
flag body
", + }) + 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 diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 33bc2bb..069b063 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -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") diff --git a/apps/api/internal/app/maildir_write.go b/apps/api/internal/app/maildir_write.go index d41763d..c695e11 100644 --- a/apps/api/internal/app/maildir_write.go +++ b/apps/api/internal/app/maildir_write.go @@ -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," +} diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index a2162a5..4618677 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -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 }