aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJason Cox <me@jasoncarloscox.com>2024-02-23 11:40:17 -0500
committerRobin Jarry <robin@jarry.cc>2024-04-02 22:22:28 +0200
commit1ce82f50d0981a9ee047e75d94c7ab496070bd4a (patch)
tree72d835ad5da26eea6d5a6acbdd916640b75f1a4e
parent54a72f83035bdf710368846e55a5b003ccab66cd (diff)
downloadaerc-1ce82f50d0981a9ee047e75d94c7ab496070bd4a.tar.gz
aerc-1ce82f50d0981a9ee047e75d94c7ab496070bd4a.zip
notmuch: add strategies for multi-file messages
A single notmuch message can represent multiple files. As a result, file-based operations like move, copy, and delete can be ambiguous. Add a new account config option, multi-file-strategy, to tell aerc how to handle these ambiguous cases. Also add options to relevant commands to set the multi-file strategy on a per-invocation basis. If no multi-file strategy is set, refuse to take file-based actions on multi-file messages. This default behavior is mostly the same as aerc's previous behavior, but a bit stricter in some cases which previously tried to be smart about multi-file operations (e.g., move and delete). Applying multi-file strategies to cross-account copy and move operations is not implemented. These operations will proceed as they have in the past -- aerc will copy/move a single file. However, for cross-account move operations, aerc will refuse to delete multiple files to prevent data loss as not all of the files are added to the destination account. See the changes to aerc-notmuch(5) for details on the currently supported multi-file strategies. Changelog-added: Tell aerc how to handle file-based operations on multi-file notmuch messages with the account config option `multi-file-strategy` and the `-m` flag to `:archive`, `:copy`, `:delete`, and `:move`. Signed-off-by: Jason Cox <me@jasoncarloscox.com> Tested-by: Maarten Aertsen <maarten@nlnetlabs.nl> Acked-by: Robin Jarry <robin@jarry.cc>
-rw-r--r--commands/msg/archive.go26
-rw-r--r--commands/msg/copy.go29
-rw-r--r--commands/msg/delete.go23
-rw-r--r--commands/msg/move.go32
-rw-r--r--commands/msg/recall.go1
-rw-r--r--commands/msg/reply.go2
-rw-r--r--doc/aerc-notmuch.5.scd24
-rw-r--r--doc/aerc.1.scd23
-rw-r--r--lib/msgstore.go18
-rw-r--r--worker/notmuch/message.go168
-rw-r--r--worker/notmuch/message_test.go264
-rw-r--r--worker/notmuch/worker.go62
-rw-r--r--worker/types/messages.go13
-rw-r--r--worker/types/mfs.go33
14 files changed, 586 insertions, 132 deletions
diff --git a/commands/msg/archive.go b/commands/msg/archive.go
index 0714a805..8c5f12b9 100644
--- a/commands/msg/archive.go
+++ b/commands/msg/archive.go
@@ -22,7 +22,19 @@ const (
var ARCHIVE_TYPES = []string{ARCHIVE_FLAT, ARCHIVE_YEAR, ARCHIVE_MONTH}
type Archive struct {
- Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType"`
+ MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS"`
+ Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType"`
+}
+
+func (a *Archive) ParseMFS(arg string) error {
+ if arg != "" {
+ mfs, ok := types.StrToStrategy[arg]
+ if !ok {
+ return fmt.Errorf("invalid multi-file strategy %s", arg)
+ }
+ a.MultiFileStrategy = &mfs
+ }
+ return nil
}
func (a *Archive) ParseArchiveType(arg string) error {
@@ -47,6 +59,10 @@ func (Archive) Aliases() []string {
return []string{"archive"}
}
+func (Archive) CompleteMFS(arg string) []string {
+ return commands.FilterList(types.StrategyStrs(), arg, nil)
+}
+
func (*Archive) CompleteType(arg string) []string {
return commands.FilterList(ARCHIVE_TYPES, arg, nil)
}
@@ -57,11 +73,13 @@ func (a Archive) Execute(args []string) error {
if err != nil {
return err
}
- err = archive(msgs, a.Type)
+ err = archive(msgs, a.MultiFileStrategy, a.Type)
return err
}
-func archive(msgs []*models.MessageInfo, archiveType string) error {
+func archive(msgs []*models.MessageInfo, mfs *types.MultiFileStrategy,
+ archiveType string,
+) error {
h := newHelper()
acct, err := h.account()
if err != nil {
@@ -111,7 +129,7 @@ func archive(msgs []*models.MessageInfo, archiveType string) error {
success := true
for dir, uids := range uidMap {
- store.Move(uids, dir, true, func(
+ store.Move(uids, dir, true, mfs, func(
msg types.WorkerMessage,
) {
switch msg := msg.(type) {
diff --git a/commands/msg/copy.go b/commands/msg/copy.go
index af44216f..9ea81055 100644
--- a/commands/msg/copy.go
+++ b/commands/msg/copy.go
@@ -14,9 +14,10 @@ import (
)
type Copy struct {
- CreateFolders bool `opt:"-p"`
- Account string `opt:"-a" complete:"CompleteAccount"`
- Folder string `opt:"folder" complete:"CompleteFolder"`
+ CreateFolders bool `opt:"-p"`
+ Account string `opt:"-a" complete:"CompleteAccount"`
+ MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS"`
+ Folder string `opt:"folder" complete:"CompleteFolder"`
}
func init() {
@@ -31,6 +32,17 @@ func (Copy) Aliases() []string {
return []string{"cp", "copy"}
}
+func (c *Copy) ParseMFS(arg string) error {
+ if arg != "" {
+ mfs, ok := types.StrToStrategy[arg]
+ if !ok {
+ return fmt.Errorf("invalid multi-file strategy %s", arg)
+ }
+ c.MultiFileStrategy = &mfs
+ }
+ return nil
+}
+
func (*Copy) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
@@ -48,6 +60,10 @@ func (c *Copy) CompleteFolder(arg string) []string {
return commands.FilterList(acct.Directories().List(), arg, nil)
}
+func (Copy) CompleteMFS(arg string) []string {
+ return commands.FilterList(types.StrategyStrs(), arg, nil)
+}
+
func (c Copy) Execute(args []string) error {
h := newHelper()
uids, err := h.markedOrSelectedUids()
@@ -60,9 +76,10 @@ func (c Copy) Execute(args []string) error {
}
if len(c.Account) == 0 {
- store.Copy(uids, c.Folder, c.CreateFolders, func(msg types.WorkerMessage) {
- c.CallBack(msg, uids, store)
- })
+ store.Copy(uids, c.Folder, c.CreateFolders, c.MultiFileStrategy,
+ func(msg types.WorkerMessage) {
+ c.CallBack(msg, uids, store)
+ })
return nil
}
diff --git a/commands/msg/delete.go b/commands/msg/delete.go
index 0abde31c..0d269eab 100644
--- a/commands/msg/delete.go
+++ b/commands/msg/delete.go
@@ -13,7 +13,9 @@ import (
"git.sr.ht/~rjarry/aerc/worker/types"
)
-type Delete struct{}
+type Delete struct {
+ MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS"`
+}
func init() {
commands.Register(Delete{})
@@ -27,7 +29,22 @@ func (Delete) Aliases() []string {
return []string{"delete", "delete-message"}
}
-func (Delete) Execute(args []string) error {
+func (d *Delete) ParseMFS(arg string) error {
+ if arg != "" {
+ mfs, ok := types.StrToStrategy[arg]
+ if !ok {
+ return fmt.Errorf("invalid multi-file strategy %s", arg)
+ }
+ d.MultiFileStrategy = &mfs
+ }
+ return nil
+}
+
+func (Delete) CompleteMFS(arg string) []string {
+ return commands.FilterList(types.StrategyStrs(), arg, nil)
+}
+
+func (d Delete) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
@@ -46,7 +63,7 @@ func (Delete) Execute(args []string) error {
marker.ClearVisualMark()
// caution, can be nil
next := findNextNonDeleted(uids, store)
- store.Delete(uids, func(msg types.WorkerMessage) {
+ store.Delete(uids, d.MultiFileStrategy, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
var s string
diff --git a/commands/msg/move.go b/commands/msg/move.go
index d2623268..c073765f 100644
--- a/commands/msg/move.go
+++ b/commands/msg/move.go
@@ -17,9 +17,10 @@ import (
)
type Move struct {
- CreateFolders bool `opt:"-p"`
- Account string `opt:"-a" complete:"CompleteAccount"`
- Folder string `opt:"folder" complete:"CompleteFolder"`
+ CreateFolders bool `opt:"-p"`
+ Account string `opt:"-a" complete:"CompleteAccount"`
+ MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS"`
+ Folder string `opt:"folder" complete:"CompleteFolder"`
}
func init() {
@@ -34,6 +35,17 @@ func (Move) Aliases() []string {
return []string{"mv", "move"}
}
+func (m *Move) ParseMFS(arg string) error {
+ if arg != "" {
+ mfs, ok := types.StrToStrategy[arg]
+ if !ok {
+ return fmt.Errorf("invalid multi-file strategy %s", arg)
+ }
+ m.MultiFileStrategy = &mfs
+ }
+ return nil
+}
+
func (*Move) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
@@ -51,6 +63,10 @@ func (m *Move) CompleteFolder(arg string) []string {
return commands.FilterList(acct.Directories().List(), arg, nil)
}
+func (Move) CompleteMFS(arg string) []string {
+ return commands.FilterList(types.StrategyStrs(), arg, nil)
+}
+
func (m Move) Execute(args []string) error {
h := newHelper()
acct, err := h.account()
@@ -71,9 +87,10 @@ func (m Move) Execute(args []string) error {
marker.ClearVisualMark()
if len(m.Account) == 0 {
- store.Move(uids, m.Folder, m.CreateFolders, func(msg types.WorkerMessage) {
- m.CallBack(msg, acct, uids, next, marker, false)
- })
+ store.Move(uids, m.Folder, m.CreateFolders, m.MultiFileStrategy,
+ func(msg types.WorkerMessage) {
+ m.CallBack(msg, acct, uids, next, marker, false)
+ })
return nil
}
@@ -158,7 +175,8 @@ func (m Move) Execute(args []string) error {
}
}
if len(appended) > 0 {
- store.Delete(appended, func(msg types.WorkerMessage) {
+ mfs := types.Refuse
+ store.Delete(appended, &mfs, func(msg types.WorkerMessage) {
m.CallBack(msg, acct, appended, next, marker, timeout)
})
}
diff --git a/commands/msg/recall.go b/commands/msg/recall.go
index 280c41b8..7c59ac85 100644
--- a/commands/msg/recall.go
+++ b/commands/msg/recall.go
@@ -75,6 +75,7 @@ func (r Recall) Execute(args []string) error {
deleteMessage := func() {
store.Delete(
uids,
+ nil,
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
diff --git a/commands/msg/reply.go b/commands/msg/reply.go
index 02cf42ce..79a0c598 100644
--- a/commands/msg/reply.go
+++ b/commands/msg/reply.go
@@ -184,7 +184,7 @@ func (r reply) Execute(args []string) error {
switch {
case c.Sent() && c.Archive() != "" && !noStore:
store.Answered([]uint32{msg.Uid}, true, nil)
- err := archive([]*models.MessageInfo{msg}, c.Archive())
+ err := archive([]*models.MessageInfo{msg}, nil, c.Archive())
if err != nil {
app.PushStatus("Archive failed", 10*time.Second)
}
diff --git a/doc/aerc-notmuch.5.scd b/doc/aerc-notmuch.5.scd
index 6837f720..202f20fd 100644
--- a/doc/aerc-notmuch.5.scd
+++ b/doc/aerc-notmuch.5.scd
@@ -68,9 +68,8 @@ options are available:
N.B.: aerc will still always show messages and not files (under notmuch,
a single message can be represented by several files), which makes the
- semantics of certain commands as *move* ambiguous: for example, if you
- try to move a message represented by several files, aerc will not know
- what to do and thus refuse.
+ semantics of certain commands as *move* ambiguous. Use *multi-file-strategy*
+ to tell aerc how to resolve these ambiguities.
*maildir-account-path* = _<path>_
Path to the maildir account relative to the *maildir-store*.
@@ -78,6 +77,25 @@ options are available:
This could be used to achieve traditional maildir one tab per account
behavior. The note on *maildir-store* also applies to this option.
+*multi-file-stategy* = _<strategy>_
+ Strategy for file operations (e.g., move, copy, delete) on messages that are
+ backed by multiple files. Possible values:
+
+ - *refuse* (default): Refuse to act.
+ - *act-all*: Act on all files.
+ - *act-one*: Act on one of the files, arbitrarily chosen, and ignore the
+ rest.
+ - *act-one-delete-rest*: Like *act-one*, but delete the remaining files.
+ - *act-dir*: Act on all files within the current folder and ignore the rest.
+ Note that this strategy only works within the maildir directories; in other
+ directories, it behaves like *refuse*.
+ - *act-dir-delete-rest*: Like *act-dir*, but delete the remaining files.
+
+ Note that the strategy has no effect on cross-account operations. Copying a
+ message across accounts will always copy a single file, arbitrarily chosen.
+ Moving a message across accounts will always copy a single file, arbitrarily
+ chosen, and refuse to delete multiple files from the source account.
+
# USAGE
Notmuch shows slightly different behavior than for example imap. Some commands
diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd
index af1ce31c..3f52fbac 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -265,7 +265,7 @@ These commands work in any context.
These commands are valid in any context that has a selected message (e.g. the
message list, the message in the message viewer, etc).
-*:archive* _<scheme>_
+*:archive* [*-m* _<strategy>_] _<scheme>_
Moves the selected message to the archive. The available schemes are:
_flat_: No special structure, all messages in the archive directory
@@ -274,6 +274,9 @@ message list, the message in the message viewer, etc).
_month_: Messages are stored in folders per year and subfolders per month
+ The *-m* option sets the multi-file strategy. See *aerc-notmuch*(5) for more
+ details.
+
*:accept* [*-e*|*-E*]
Accepts an iCalendar meeting invitation.
@@ -288,8 +291,8 @@ message list, the message in the message viewer, etc).
*-E*: Forces *[compose].edit-headers* = _false_ for this message only.
-*:copy* [*-p*] [*-a* _<account>_] _<folder>_++
-*:cp* [*-p*] [*-a* _<account>_] _<folder>_
+*:copy* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_++
+*:cp* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_
Copies the selected message(s) to _<folder>_.
*-p*: Create _<folder>_ if it does not exist.
@@ -297,6 +300,8 @@ message list, the message in the message viewer, etc).
*-a*: Copy to _<folder>_ of _<account>_. If _<folder>_ does
not exist, it will be created whether or not *-p* is used.
+ *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details.
+
*:decline* [*-e*|*-E*]
Declines an iCalendar meeting invitation.
@@ -304,10 +309,12 @@ message list, the message in the message viewer, etc).
*-E*: Forces *[compose].edit-headers* = _false_ for this message only.
-*:delete*++
-*:delete-message*
+*:delete* [*-m* _<strategy>_]++
+*:delete-message* [*-m* _<strategy>_]
Deletes the selected message.
+ *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details.
+
*:envelope* [*-h*] [*-s* _<format-specifier>_]
Opens the message envelope in a dialog popup.
@@ -351,8 +358,8 @@ message list, the message in the message viewer, etc).
*-E*: Forces *[compose].edit-headers* = _false_ for this message only.
-*:move* [*-p*] [*-a* _<account>_] _<folder>_++
-*:mv* [*-p*] [*-a* _<account>_] _<folder>_
+*:move* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_++
+*:mv* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_
Moves the selected message(s) to _<folder>_.
*-p*: Create _<folder>_ if it does not exist.
@@ -360,6 +367,8 @@ message list, the message in the message viewer, etc).
*-a*: Move to _<folder>_ of _<account>_. If _<folder>_ does
not exist, it will be created whether or not *-p* is used.
+ *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details.
+
*:patch* _<args ...>_
Patch management sub-commands. See *aerc-patch*(7) for more details.
diff --git a/lib/msgstore.go b/lib/msgstore.go
index 274e42ba..d11d280e 100644
--- a/lib/msgstore.go
+++ b/lib/msgstore.go
@@ -572,14 +572,14 @@ func (store *MessageStore) doThreadFolding(uid uint32, hide bool, toggle bool) e
return nil
}
-func (store *MessageStore) Delete(uids []uint32,
+func (store *MessageStore) Delete(uids []uint32, mfs *types.MultiFileStrategy,
cb func(msg types.WorkerMessage),
) {
for _, uid := range uids {
store.Deleted[uid] = nil
}
- store.worker.PostAction(&types.DeleteMessages{Uids: uids},
+ store.worker.PostAction(&types.DeleteMessages{Uids: uids, MultiFileStrategy: mfs},
func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Error); ok {
store.revertDeleted(uids)
@@ -601,7 +601,7 @@ func (store *MessageStore) revertDeleted(uids []uint32) {
}
func (store *MessageStore) Copy(uids []uint32, dest string, createDest bool,
- cb func(msg types.WorkerMessage),
+ mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage),
) {
if createDest {
store.worker.PostAction(&types.CreateDirectory{
@@ -611,8 +611,9 @@ func (store *MessageStore) Copy(uids []uint32, dest string, createDest bool,
}
store.worker.PostAction(&types.CopyMessages{
- Destination: dest,
- Uids: uids,
+ Destination: dest,
+ Uids: uids,
+ MultiFileStrategy: mfs,
}, func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Done); ok {
store.triggerMailAdded(dest)
@@ -622,7 +623,7 @@ func (store *MessageStore) Copy(uids []uint32, dest string, createDest bool,
}
func (store *MessageStore) Move(uids []uint32, dest string, createDest bool,
- cb func(msg types.WorkerMessage),
+ mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage),
) {
for _, uid := range uids {
store.Deleted[uid] = nil
@@ -636,8 +637,9 @@ func (store *MessageStore) Move(uids []uint32, dest string, createDest bool,
}
store.worker.PostAction(&types.MoveMessages{
- Destination: dest,
- Uids: uids,
+ Destination: dest,
+ Uids: uids,
+ MultiFileStrategy: mfs,
}, func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Error:
diff --git a/worker/notmuch/message.go b/worker/notmuch/message.go
index 09850d64..daaec7d0 100644
--- a/worker/notmuch/message.go
+++ b/worker/notmuch/message.go
@@ -17,6 +17,7 @@ import (
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/lib"
notmuch "git.sr.ht/~rjarry/aerc/worker/notmuch/lib"
+ "git.sr.ht/~rjarry/aerc/worker/types"
)
type Message struct {
@@ -173,87 +174,144 @@ func (m *Message) ModifyTags(add, remove []string) error {
return m.db.MsgModifyTags(m.key, add, remove)
}
-func (m *Message) Remove(dir maildir.Dir) error {
- filenames, err := m.db.MsgFilenames(m.key)
+func (m *Message) Remove(curDir maildir.Dir, mfs types.MultiFileStrategy) error {
+ rm, del, err := m.filenamesForStrategy(mfs, curDir)
if err != nil {
return err
}
- for _, filename := range filenames {
- if dirContains(dir, filename) {
- err := m.db.DeleteMessage(filename)
- if err != nil {
- return err
- }
- if err := os.Remove(filename); err != nil {
- return err
- }
-
- return nil
- }
- }
-
- return fmt.Errorf("no matching message file found in %s", string(dir))
+ rm = append(rm, del...)
+ return m.deleteFiles(rm)
}
-func (m *Message) Copy(target maildir.Dir) error {
- filename, err := m.Filename()
+func (m *Message) Copy(curDir, destDir maildir.Dir, mfs types.MultiFileStrategy) error {
+ cp, del, err := m.filenamesForStrategy(mfs, curDir)
if err != nil {
return err
}
- source, key := parseFilename(filename)
- if key == "" {
- return fmt.Errorf("failed to parse message filename: %s", filename)
- }
+ for _, filename := range cp {
+ source, key := parseFilename(filename)
+ if key == "" {
+ return fmt.Errorf("failed to parse message filename: %s", filename)
+ }
- newKey, err := source.Copy(target, key)
- if err != nil {
- return err
+ newKey, err := source.Copy(destDir, key)
+ if err != nil {
+ return err
+ }
+ newFilename, err := destDir.Filename(newKey)
+ if err != nil {
+ return err
+ }
+ _, err = m.db.IndexFile(newFilename)
+ if err != nil {
+ return err
+ }
}
- newFilename, err := target.Filename(newKey)
+
+ return m.deleteFiles(del)
+}
+
+func (m *Message) Move(curDir, destDir maildir.Dir, mfs types.MultiFileStrategy) error {
+ move, del, err := m.filenamesForStrategy(mfs, curDir)
if err != nil {
return err
}
- _, err = m.db.IndexFile(newFilename)
- return err
-}
-func (m *Message) Move(srcDir, destDir maildir.Dir) error {
- var src string
+ for _, filename := range move {
+ // Remove encoded UID information from the key to prevent sync issues
+ name := lib.StripUIDFromMessageFilename(filepath.Base(filename))
+ dest := filepath.Join(string(destDir), "cur", name)
- filenames, err := m.db.MsgFilenames(m.key)
- if err != nil {
- return err
+ if err := os.Rename(filename, dest); err != nil {
+ return err
+ }
+
+ if _, err = m.db.IndexFile(dest); err != nil {
+ return err
+ }
+
+ if err := m.db.DeleteMessage(filename); err != nil {
+ return err
+ }
}
+
+ return m.deleteFiles(del)
+}
+
+func (m *Message) deleteFiles(filenames []string) error {
for _, filename := range filenames {
- if dirContains(srcDir, filename) {
- src = filename
- break
+ if err := os.Remove(filename); err != nil {
+ return err
+ }
+
+ if err := m.db.DeleteMessage(filename); err != nil {
+ return err
}
}
- if src == "" {
- return fmt.Errorf("no matching message file found in %s", string(srcDir))
+ return nil
+}
+
+func (m *Message) filenamesForStrategy(strategy types.MultiFileStrategy,
+ curDir maildir.Dir,
+) (act, del []string, err error) {
+ filenames, err := m.db.MsgFilenames(m.key)
+ if err != nil {
+ return nil, nil, err
}
+ return filterForStrategy(filenames, strategy, curDir)
+}
- // Remove encoded UID information from the key to prevent sync issues
- name := lib.StripUIDFromMessageFilename(filepath.Base(src))
- dest := filepath.Join(string(destDir), "cur", name)
+func filterForStrategy(filenames []string, strategy types.MultiFileStrategy,
+ curDir maildir.Dir,
+) (act, del []string, err error) {
+ if curDir == "" &&
+ (strategy == types.ActDir || strategy == types.ActDirDelRest) {
+ strategy = types.Refuse
+ }
- if err := os.Rename(src, dest); err != nil {
- return err
+ if len(filenames) < 2 {
+ return filenames, []string{}, nil
}
- if _, err = m.db.IndexFile(dest); err != nil {
- return err
+ act = []string{}
+ rest := []string{}
+ switch strategy {
+ case types.Refuse:
+ return nil, nil, fmt.Errorf("refusing to act on multiple files")
+ case types.ActAll:
+ act = filenames
+ case types.ActOne:
+ fallthrough
+ case types.ActOneDelRest:
+ act = filenames[:1]
+ rest = filenames[1:]
+ case types.ActDir:
+ fallthrough
+ case types.ActDirDelRest:
+ for _, filename := range filenames {
+ if filepath.Dir(filepath.Dir(filename)) == string(curDir) {
+ act = append(act, filename)
+ } else {
+ rest = append(rest, filename)
+ }
+ }
+ default:
+ return nil, nil, fmt.Errorf("invalid multi-file strategy %v", strategy)
}
- if err := m.db.DeleteMessage(src); err != nil {
- return err
+ switch strategy {
+ case types.ActOneDelRest:
+ fallthrough
+ case types.ActDirDelRest:
+ del = rest
+ default:
+ del = []string{}
}
- return nil
+ return act, del, nil
}
func parseFilename(filename string) (maildir.Dir, string) {
@@ -270,13 +328,3 @@ func parseFilename(filename string) (maildir.Dir, string) {
key := split[0]
return maildir.Dir(dir), key
}
-
-func dirContains(dir maildir.Dir, filename string) bool {
- for _, sub := range []string{"cur", "new"} {
- match, _ := filepath.Match(filepath.Join(string(dir), sub, "*"), filename)
- if match {
- return true
- }
- }
- return false
-}
diff --git a/worker/notmuch/message_test.go b/worker/notmuch/message_test.go
new file mode 100644
index 00000000..51fcdb09
--- /dev/null
+++ b/worker/notmuch/message_test.go
@@ -0,0 +1,264 @@
+//go:build notmuch
+// +build notmuch
+
+package notmuch
+
+import (
+ "testing"
+
+ "git.sr.ht/~rjarry/aerc/worker/types"
+ "github.com/emersion/go-maildir"
+)
+
+func TestFilterForStrategy(t *testing.T) {
+ tests := []struct {
+ filenames []string
+ strategy types.MultiFileStrategy
+ curDir string
+ expectedAct []string
+ expectedDel []string
+ expectedErr bool
+ }{
+ // if there's only one file, always act on it
+ {
+ filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ strategy: types.Refuse,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ strategy: types.ActAll,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ strategy: types.ActOne,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ strategy: types.ActOneDelRest,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ strategy: types.ActDir,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ strategy: types.ActDirDelRest,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{},
+ },
+
+ // follow strategy for multiple files
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.Refuse,
+ curDir: "/h/j/m/B",
+ expectedErr: true,
+ },
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActAll,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActOne,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActOneDelRest,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ },
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActDir,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ },
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActDirDelRest,
+ curDir: "/h/j/m/B",
+ expectedAct: []string{
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ },
+ expectedDel: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/C/new/d.e.f",
+ },
+ },
+
+ // refuse to act on multiple files for ActDir and friends if
+ // no current dir is provided
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActDir,
+ curDir: "",
+ expectedErr: true,
+ },
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActDirDelRest,
+ curDir: "",
+ expectedErr: true,
+ },
+
+ // act on multiple files w/o current dir for other strategies
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActAll,
+ curDir: "",
+ expectedAct: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActOne,
+ curDir: "",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{},
+ },
+ {
+ filenames: []string{
+ "/h/j/m/A/cur/a.b.c:2,",
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ strategy: types.ActOneDelRest,
+ curDir: "",
+ expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
+ expectedDel: []string{
+ "/h/j/m/B/new/b.c.d",
+ "/h/j/m/B/cur/c.d.e:2,S",
+ "/h/j/m/C/new/d.e.f",
+ },
+ },
+ }
+
+ for i, test := range tests {
+ act, del, err := filterForStrategy(test.filenames, test.strategy,
+ maildir.Dir(test.curDir))
+
+ if test.expectedErr && err == nil {
+ t.Errorf("[test %d] got nil, expected error", i)
+ }
+
+ if !test.expectedErr && err != nil {
+ t.Errorf("[test %d] got %v, expected nil", i, err)
+ }
+
+ if !arrEq(act, test.expectedAct) {
+ t.Errorf("[test %d] got %v, expected %v", i, act, test.expectedAct)
+ }
+
+ if !arrEq(del, test.expectedDel) {
+ t.Errorf("[test %d] got %v, expected %v", i, del, test.expectedDel)
+ }
+ }
+}
+
+func arrEq(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/worker/notmuch/worker.go b/worker/notmuch/worker.go
index aa2da391..fe41446e 100644
--- a/worker/notmuch/worker.go
+++ b/worker/notmuch/worker.go
@@ -55,6 +55,7 @@ type worker struct {
headers []string
headersExclude []string
state uint64
+ mfs types.MultiFileStrategy
}
// NewWorker creates a new notmuch worker with the provided worker.
@@ -243,6 +244,16 @@ func (w *worker) handleConfigure(msg *types.Configure) error {
w.headers = msg.Config.Headers
w.headersExclude = msg.Config.HeadersExclude
+ mfs := msg.Config.Params["multi-file-strategy"]
+ if mfs != "" {
+ w.mfs, ok = types.StrToStrategy[mfs]
+ if !ok {
+ return fmt.Errorf("invalid multi-file strategy %s", mfs)
+ }
+ } else {
+ w.mfs = types.Refuse
+ }
+
return nil
}
@@ -755,17 +766,12 @@ func (w *worker) handleDeleteMessages(msg *types.DeleteMessages) error {
var deleted []uint32
- // With notmuch, two identical files can be referenced under
- // the same index key, even if they exist in two different
- // folders. So in order to remove the message from the right
- // maildir folder we need to pass a hint to Remove() so it
- // can purge the right file.
folders, _ := w.store.FolderMap()
- path, ok := folders[w.currentQueryName]
- if !ok {
- w.err(msg, fmt.Errorf("Can only delete file from a maildir folder"))
- w.done(msg)
- return nil
+ curDir := folders[w.currentQueryName]
+
+ mfs := w.mfs
+ if msg.MultiFileStrategy != nil {
+ mfs = *msg.MultiFileStrategy
}
for _, uid := range msg.Uids {
@@ -775,7 +781,7 @@ func (w *worker) handleDeleteMessages(msg *types.DeleteMessages) error {
w.err(msg, err)
continue
}
- if err := m.Remove(path); err != nil {
+ if err := m.Remove(curDir, mfs); err != nil {
w.w.Errorf("could not remove message: %v", err)
w.err(msg, err)
continue
@@ -804,13 +810,20 @@ func (w *worker) handleCopyMessages(msg *types.CopyMessages) error {
return fmt.Errorf("Can only copy file to a maildir folder")
}
+ curDir := folders[w.currentQueryName]
+
+ mfs := w.mfs
+ if msg.MultiFileStrategy != nil {
+ mfs = *msg.MultiFileStrategy
+ }
+
for _, uid := range msg.Uids {
m, err := w.msgFromUid(uid)
if err != nil {
w.w.Errorf("could not get message: %v", err)
return err
}
- if err := m.Copy(dest); err != nil {
+ if err := m.Copy(curDir, dest, mfs); err != nil {
w.w.Errorf("could not copy message: %v", err)
return err
}
@@ -839,6 +852,13 @@ func (w *worker) handleMoveMessages(msg *types.MoveMessages) error {
return fmt.Errorf("Can only move file to a maildir folder")
}
+ curDir := folders[w.currentQueryName]
+
+ mfs := w.mfs
+ if msg.MultiFileStrategy != nil {
+ mfs = *msg.MultiFileStrategy
+ }
+
var err error
for _, uid := range msg.Uids {
m, err := w.msgFromUid(uid)
@@ -846,22 +866,8 @@ func (w *worker) handleMoveMessages(msg *types.MoveMessages) error {
w.w.Errorf("could not get message: %v", err)
break
}
- filenames, err := m.db.MsgFilenames(m.key)
- if err != nil {
- return err
- }
- // In the future, it'd be nice if we could overload move with
- // the possibility to affect some or all of the files
- // corresponding to a message.
- if len(filenames) > 1 {
- return fmt.Errorf("Cannot move: message %d has multiple files", m.uid)
- }
- source, key := parseFilename(filenames[0])
- if key == "" {
- return fmt.Errorf("failed to parse message filename: %s", filenames[0])
- }
- if err := m.Move(source, dest); err != nil {
- w.w.Errorf("could not copy message: %v", err)
+ if err := m.Move(curDir, dest, mfs); err != nil {
+ w.w.Errorf("could not move message: %v", err)
break
}
moved = append(moved, uid)
diff --git a/worker/types/messages.go b/worker/types/messages.go
index 90d3d7bb..bbc430ca 100644
--- a/worker/types/messages.go
+++ b/worker/types/messages.go
@@ -167,7 +167,8 @@ type FetchMessageFlags struct {
type DeleteMessages struct {
Message
- Uids []uint32
+ Uids []uint32
+ MultiFileStrategy *MultiFileStrategy
}
// Flag messages with different mail types
@@ -186,14 +187,16 @@ type AnsweredMessages struct {
type CopyMessages struct {
Message
- Destination string
- Uids []uint32
+ Destination string
+ Uids []uint32
+ MultiFileStrategy *MultiFileStrategy
}
type MoveMessages struct {
Message
- Destination string
- Uids []uint32
+ Destination string
+ Uids []uint32
+ MultiFileStrategy *MultiFileStrategy
}
type AppendMessage struct {
diff --git a/worker/types/mfs.go b/worker/types/mfs.go
new file mode 100644
index 00000000..071eda1d
--- /dev/null
+++ b/worker/types/mfs.go
@@ -0,0 +1,33 @@
+package types
+
+// MultiFileStrategy represents a strategy for taking file-based actions (e.g.,
+// move, copy, delete) on messages that are represented by more than one file.
+// These strategies are only used by the notmuch backend but are defined in this
+// package to prevent import cycles.
+type MultiFileStrategy uint
+
+const (
+ Refuse MultiFileStrategy = iota
+ ActAll
+ ActOne
+ ActOneDelRest
+ ActDir
+ ActDirDelRest
+)
+
+var StrToStrategy = map[string]MultiFileStrategy{
+ "refuse": Refuse,
+ "act-all": ActAll,
+ "act-one": ActOne,
+ "act-one-delete-rest": ActOneDelRest,
+ "act-dir": ActDir,
+ "act-dir-delete-rest": ActDirDelRest,
+}
+
+func StrategyStrs() []string {
+ strs := make([]string, len(StrToStrategy))
+ for s := range StrToStrategy {
+ strs = append(strs, s)
+ }
+ return strs
+}