Files
Folker Schwesinger 0a43480356 maildir: Fix deadlock when deleting messages
Recently, locking was added to the maildir Container struct. Since both
DeleteAll() and Message() take the container lock, calling Message() from
DeleteAll() deadlocks. Deleting any message in a maildir-backed account
triggers this code path.
Fix the issue by adding a helper function message(), that relies on the
caller to take the lock and use it in DeleteAll(). Convert Message() to
a thin wrapper around message() handling synchronization.

Fixes: ba1d070b ("maildir: protect container maps against concurrent modifications")
Signed-off-by: Folker Schwesinger <dev@folker-schwesinger.de>
Acked-by: Simon Martin <simon@nasilyan.com>
2026-10-10 11:53:40 +02:00

206 lines
5.2 KiB
Go

package maildir
import (
"fmt"
"os"
"path/filepath"
"slices"
"sync"
"github.com/emersion/go-maildir"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/lib"
)
// A Container is a directory which contains other directories which adhere to
// the Maildir spec
type Container struct {
sync.RWMutex
Store *lib.MaildirStore
recentUIDS map[models.UID]struct{} // used to set the recent flag
msgCache map[models.UID]*Message
}
// NewContainer creates a new container at the specified directory
func NewContainer(dir string, maildirpp bool) (*Container, error) {
store, err := lib.NewMaildirStore(dir, maildirpp)
if err != nil {
return nil, err
}
return &Container{
Store: store,
recentUIDS: make(map[models.UID]struct{}),
msgCache: make(map[models.UID]*Message),
}, nil
}
// SyncNewMail adds emails from new to cur, tracking them
func (c *Container) SyncNewMail(dir maildir.Dir) error {
unseen, err := dir.Unseen()
if err != nil {
return err
}
c.Lock()
for _, msg := range unseen {
c.recentUIDS[models.UID(msg.Key())] = struct{}{}
}
c.Unlock()
return nil
}
// OpenDirectory opens an existing maildir in the container by name, moves new
// messages into cur, and registers the new keys in the UIDStore.
func (c *Container) OpenDirectory(name string) (maildir.Dir, error) {
dir := c.Store.Dir(name)
if err := c.SyncNewMail(dir); err != nil {
return dir, err
}
return dir, nil
}
// IsRecent returns if a uid has the Recent flag set
func (c *Container) IsRecent(uid models.UID) bool {
c.RLock()
_, ok := c.recentUIDS[uid]
c.RUnlock()
return ok
}
// ClearRecentFlag removes the Recent flag from the message with the given uid
func (c *Container) ClearRecentFlag(uid models.UID) {
c.Lock()
delete(c.recentUIDS, uid)
c.Unlock()
}
// UIDs fetches the unique message identifiers for the maildir
func (c *Container) UIDs(d maildir.Dir) ([]models.UID, error) {
// messages, err := d.Keys()
messages, err := d.Messages()
if err != nil && len(messages) == 0 {
return nil, fmt.Errorf("could not get keys for %s: %w", d, err)
}
if err != nil {
log.Errorf("could not get all keys for %s: %s", d, err.Error())
}
// Reconstruct the cache rather than modifying it, so any stale entries
// are purged.
newCache := make(map[models.UID]*Message)
var uids []models.UID
c.Lock()
defer c.Unlock()
for _, msg := range messages {
key := msg.Key()
uid := models.UID(key)
if m, ok := c.msgCache[uid]; ok {
// If cached message is already present, update msg so
// any path (flag) changes by external programs are
// picked up.
m.msg = msg
newCache[uid] = m
} else {
newCache[uid] = &Message{
dir: d,
uid: uid,
key: key,
msg: msg,
}
}
uids = append(uids, uid)
}
c.msgCache = newCache
slices.Sort(uids)
return uids, err
}
// message returns a Message struct for the given UID and maildir.
// Callers should hold the container lock.
func (c *Container) message(d maildir.Dir, uid models.UID) (*Message, error) {
if m, ok := c.msgCache[uid]; ok && m.dir == d {
return m, nil
}
m := &Message{
dir: d,
uid: uid,
key: string(uid),
}
c.msgCache[uid] = m
return m, nil
}
// Message returns a Message struct for the given UID and maildir
func (c *Container) Message(d maildir.Dir, uid models.UID) (*Message, error) {
c.Lock()
defer c.Unlock()
return c.message(d, uid)
}
// DeleteAll deletes a set of messages by UID and returns the subset of UIDs
// which were successfully deleted, stopping upon the first error.
func (c *Container) DeleteAll(d maildir.Dir, uids []models.UID) ([]models.UID, error) {
c.Lock()
defer c.Unlock()
var success []models.UID
for _, uid := range uids {
msg, err := c.message(d, uid)
if err != nil {
return success, err
}
if err := msg.Remove(); err != nil {
return success, err
}
success = append(success, uid)
delete(c.msgCache, uid)
}
return success, nil
}
func (c *Container) CopyAll(
dest maildir.Dir, src maildir.Dir, uids []models.UID,
) error {
for _, uid := range uids {
if err := c.copyMessage(dest, src, uid); err != nil {
return fmt.Errorf("could not copy message %s: %w", uid, err)
}
}
return nil
}
func (c *Container) copyMessage(
dest maildir.Dir, src maildir.Dir, uid models.UID,
) error {
msg, err := src.MessageByKey(string(uid))
if err != nil {
return fmt.Errorf("failed to retrieve message %q: %w", uid, err)
}
_, err = msg.CopyTo(dest)
return err
}
func (c *Container) MoveAll(dest maildir.Dir, src maildir.Dir, uids []models.UID) ([]models.UID, error) {
var success []models.UID
for _, uid := range uids {
if err := c.moveMessage(dest, src, uid); err != nil {
return success, fmt.Errorf("could not move message %s: %w", uid, err)
}
success = append(success, uid)
}
return success, nil
}
func (c *Container) moveMessage(dest maildir.Dir, src maildir.Dir, uid models.UID) error {
msg, err := src.MessageByKey(string(uid))
if err != nil {
return fmt.Errorf("failed to retrieve message %q: %w", uid, err)
}
path := msg.Filename()
// Remove encoded UID information from the key to prevent sync issues
name := lib.StripUIDFromMessageFilename(filepath.Base(path))
destPath := filepath.Join(string(dest), "cur", name)
return os.Rename(path, destPath)
}