From 115dabb6346383c88525586c2ec75d60df6f25d3 Mon Sep 17 00:00:00 2001
From: Robin Jarry <robin@jarry.cc>
Date: Thu, 24 Feb 2022 21:10:30 +0100
Subject: [PATCH] pipe: allow piping multiple marked messages

When messages are marked, pipe their contents into the specified
command. The messages are ordered according to their respective
Message-Id headers. This allows applying complete patch series with
a single command.

When piping more than one message, make sure to write them in the mbox
format as git am expects them to be.

Link: https://en.wikipedia.org/wiki/Mbox
Link: https://github.com/git/git/blob/v2.35.1/builtin/mailsplit.c#L15-L44
Signed-off-by: Robin Jarry <robin@jarry.cc>
Reviewed-by: Koni Marti <koni.marti@gmail.com>
Tested-by: akspecs <akspecs@gmail.com>
---
 commands/msg/pipe.go | 115 ++++++++++++++++++++++++++++++++++++++-----
 doc/aerc.1.scd       |   3 ++
 2 files changed, 107 insertions(+), 11 deletions(-)

diff --git a/commands/msg/pipe.go b/commands/msg/pipe.go
index 58764fb..5d8a042 100644
--- a/commands/msg/pipe.go
+++ b/commands/msg/pipe.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io"
 	"os/exec"
+	"sort"
 	"time"
 
 	"git.sr.ht/~rjarry/aerc/commands"
@@ -108,22 +109,73 @@ func (Pipe) Execute(aerc *widgets.Aerc, args []string) error {
 	}
 
 	if pipeFull {
-		store := provider.Store()
-		if store == nil {
-			return errors.New("Cannot perform action. Messages still loading")
-		}
-		msg, err := provider.SelectedMessage()
+		var uids []uint32
+		var title string
+
+		h := newHelper(aerc)
+		store, err := h.store()
 		if err != nil {
 			return err
 		}
-		store.FetchFull([]uint32{msg.Uid}, func(fm *types.FullMessage) {
-			if background {
-				doExec(fm.Content.Reader)
-			} else {
-				doTerm(fm.Content.Reader, fmt.Sprintf(
-					"%s <%s", cmd[0], msg.Envelope.Subject))
+		uids, err = h.markedOrSelectedUids()
+		if err != nil {
+			return err
+		}
+
+		if len(uids) == 1 {
+			info := store.Messages[uids[0]]
+			if info != nil {
+				envelope := info.Envelope
+				if envelope != nil {
+					title = envelope.Subject
+				}
+			}
+		}
+		if title == "" {
+			title = fmt.Sprintf("%d messages", len(uids))
+		}
+
+		var messages []*types.FullMessage
+		done := make(chan bool, 1)
+
+		store.FetchFull(uids, func(fm *types.FullMessage) {
+			messages = append(messages, fm)
+			if len(messages) == len(uids) {
+				done <- true
 			}
 		})
+
+		go func() {
+			select {
+			case <-done:
+				break
+			case <-time.After(30 * time.Second):
+				// TODO: find a better way to determine if store.FetchFull()
+				// has finished with some errors.
+				aerc.PushError("Failed to fetch all messages")
+				if len(messages) == 0 {
+					return
+				}
+			}
+
+			// Sort all messages by increasing Message-Id header.
+			// This will ensure that patch series are applied in order.
+			sort.Slice(messages, func(i, j int) bool {
+				infoi := store.Messages[messages[i].Content.Uid]
+				infoj := store.Messages[messages[j].Content.Uid]
+				if infoi == nil || infoj == nil {
+					return false
+				}
+				return infoi.Envelope.MessageId < infoj.Envelope.MessageId
+			})
+
+			reader := newMessagesReader(messages)
+			if background {
+				doExec(reader)
+			} else {
+				doTerm(reader, fmt.Sprintf("%s <%s", cmd[0], title))
+			}
+		}()
 	} else if pipePart {
 		p := provider.SelectedMessagePart()
 		if p == nil {
@@ -143,3 +195,44 @@ func (Pipe) Execute(aerc *widgets.Aerc, args []string) error {
 
 	return nil
 }
+
+// The actual sender address does not matter, nor does the date. This is mostly indended
+// for git am which requires separators to look like something valid.
+// https://github.com/git/git/blame/v2.35.1/builtin/mailsplit.c#L15-L44
+var mboxSeparator []byte = []byte("From ???@??? Tue Jun 23 16:32:49 1981\n")
+
+type messagesReader struct {
+	messages        []*types.FullMessage
+	mbox            bool
+	separatorNeeded bool
+}
+
+func newMessagesReader(messages []*types.FullMessage) io.Reader {
+	needMboxSeparator := len(messages) > 1
+	return &messagesReader{messages, needMboxSeparator, needMboxSeparator}
+}
+
+func (mr *messagesReader) Read(p []byte) (n int, err error) {
+	for len(mr.messages) > 0 {
+		if mr.separatorNeeded {
+			offset := copy(p, mboxSeparator)
+			n, err = mr.messages[0].Content.Reader.Read(p[offset:])
+			n += offset
+			mr.separatorNeeded = false
+		} else {
+			n, err = mr.messages[0].Content.Reader.Read(p)
+		}
+		if err == io.EOF {
+			mr.messages = mr.messages[1:]
+			mr.separatorNeeded = mr.mbox
+		}
+		if n > 0 || err != io.EOF {
+			if err == io.EOF && len(mr.messages) > 0 {
+				// Don't return EOF yet. More messages remain.
+				err = nil
+			}
+			return n, err
+		}
+	}
+	return 0, io.EOF
+}
diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd
index 648bde6..27384ad 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -146,6 +146,9 @@ message list, the message in the message viewer, etc).
 	message part is used in the message viewer and the full message is used in
 	the message list.
 
+	Operates on multiple messages when they are marked. When piping multiple
+	messages, aerc will write them with mbox format separators.
+
 	*-b*: Run the command in the background instead of opening a terminal tab
 
 	*-m*: Pipe the full message