aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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
+}