From 304a308a8e4c48d1372547128f3a001f7d39facb Mon Sep 17 00:00:00 2001 From: Robert Kroeger Date: Mon, 7 Jul 2025 09:11:56 -0400 Subject: [PATCH 1/3] Preparatory refactoring for adding batch piping To implement batch piping, it's desirable to have the showPager capability available from multiple places. Hoist it to permit this usage. --- cmd/cmdg/cmdg.go | 21 +++++++++++++++++++++ cmd/cmdg/view_openmessage.go | 25 +++---------------------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/cmd/cmdg/cmdg.go b/cmd/cmdg/cmdg.go index e55b3d8..5fd2c5b 100644 --- a/cmd/cmdg/cmdg.go +++ b/cmd/cmdg/cmdg.go @@ -31,11 +31,14 @@ import ( "fmt" "io/ioutil" "os" + "os/exec" "path" + "strings" "sync" "syscall" "time" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/ThomasHabets/cmdg/pkg/cmdg" @@ -294,3 +297,21 @@ func main() { log.Fatal(err) } } + +func showPager(ctx context.Context, keys *input.Input, content string) error { + keys.Stop() + defer keys.Start() + + cmd := exec.CommandContext(ctx, pagerBinary) + cmd.Stdin = strings.NewReader(content) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return errors.Wrapf(err, "failed to start pager %q", pagerBinary) + } + if err := cmd.Wait(); err != nil { + return errors.Wrapf(err, "pager %q failed", pagerBinary) + } + log.Infof("Pager finished") + return nil +} diff --git a/cmd/cmdg/view_openmessage.go b/cmd/cmdg/view_openmessage.go index 495704f..a8ddfb5 100644 --- a/cmd/cmdg/view_openmessage.go +++ b/cmd/cmdg/view_openmessage.go @@ -5,7 +5,6 @@ import ( "context" "flag" "fmt" - "os" "os/exec" "regexp" "strings" @@ -181,7 +180,7 @@ func (ov *OpenMessageView) Draw(lines []string, scroll int) error { scroll, min(scroll+contentSpace, len(lines)), len(lines), - min(100,int(100*float64(scroll+contentSpace) / float64(len(lines)))), + min(100, int(100*float64(scroll+contentSpace)/float64(len(lines)))), searching, ) line++ @@ -681,7 +680,7 @@ func (ov *OpenMessageView) Run(ctx context.Context) (*MessageViewOp, error) { ov.errors <- errors.Wrapf(err, "failed run pipe command: %q", buf.String()) break } - ov.errors <- ov.showPager(ctx, buf.String()) + ov.errors <- showPager(ctx, ov.keys, buf.String()) case input.Backspace, input.CtrlH, input.PgUp, "Meta-v": scroll = ov.scroll(ctx, len(lines), scroll, -(ov.screen.Height - 10)) ov.Draw(lines, scroll) @@ -698,25 +697,7 @@ func (ov *OpenMessageView) showRaw(ctx context.Context) error { if err != nil { return errors.Wrapf(err, "Fetching raw msg") } - return ov.showPager(ctx, m) -} - -func (ov *OpenMessageView) showPager(ctx context.Context, content string) error { - ov.keys.Stop() - defer ov.keys.Start() - - cmd := exec.CommandContext(ctx, pagerBinary) - cmd.Stdin = strings.NewReader(content) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - return errors.Wrapf(err, "failed to start pager %q", pagerBinary) - } - if err := cmd.Wait(); err != nil { - return errors.Wrapf(err, "pager %q failed", pagerBinary) - } - log.Infof("Pager finished") - return nil + return showPager(ctx, ov.keys, m) } func (ov *OpenMessageView) scroll(ctx context.Context, lines, scroll, inc int) int { From a2bd928657d5b127cca3360f5dc5e2e5794b36ac Mon Sep 17 00:00:00 2001 From: Robert Kroeger Date: Thu, 10 Jul 2025 05:39:50 -0400 Subject: [PATCH 2/3] Adjust GetFrom for mbox generation mbox files need the raw address to parse successfully. Create a new entry point to replace GetFrom that satisfies this use case. Change the name of GetFrom to reflect its actual behaviour. --- cmd/cmdg/view_messagelist.go | 2 +- pkg/cmdg/message.go | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/cmd/cmdg/view_messagelist.go b/cmd/cmdg/view_messagelist.go index 6e894eb..1bb6e1f 100644 --- a/cmd/cmdg/view_messagelist.go +++ b/cmd/cmdg/view_messagelist.go @@ -430,7 +430,7 @@ func (mv *MessageView) Run(ctx context.Context) error { log.Infof("Failed to parse mail date: %v", err) tm = "???" } - from, err := curmsg.GetFrom(ctx) + from, err := curmsg.GetFromNameOrAddress(ctx) if err != nil { return err } diff --git a/pkg/cmdg/message.go b/pkg/cmdg/message.go index 1ecbef5..9090006 100644 --- a/pkg/cmdg/message.go +++ b/pkg/cmdg/message.go @@ -449,8 +449,9 @@ func (msg *Message) GetReplyToAll(ctx context.Context) (string, string, error) { return from, strings.Join(filteredEmails(from, cc), ", "), err } -// GetFrom returns email address (not name) of sender. -func (msg *Message) GetFrom(ctx context.Context) (string, error) { +// GetFromNameOrAddress returns the name (preferred) or address of the +// sender. +func (msg *Message) GetFromNameOrAddress(ctx context.Context) (string, error) { s, err := msg.GetHeader(ctx, "From") if err != nil { return "", err @@ -466,6 +467,20 @@ func (msg *Message) GetFrom(ctx context.Context) (string, error) { return a.Address, nil } +// GetFromAddress returns the address of the sender. +func (msg *Message) GetFromAddress(ctx context.Context) (string, error) { + s, err := msg.GetHeader(ctx, "From") + if err != nil { + return "", err + } + a, err := mail.ParseAddress(s) + if err != nil { + log.Warningf("%q is not a valid address: %v", s, err) + return "", err + } + return a.Address, nil +} + // Label is a gmail label. type Label struct { ID string From 7344eef969896a16fd582215c1a56d3dee3d4a16 Mon Sep 17 00:00:00 2001 From: Robert Kroeger Date: Sun, 6 Jul 2025 17:49:14 -0400 Subject: [PATCH 3/3] Pipe selected messages as mbox Provide an implementation of | within the message list that generates a mbox file corresponding to the selection and pipes it into a provided external command. --- cmd/cmdg/view_messagelist.go | 96 ++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/cmd/cmdg/view_messagelist.go b/cmd/cmdg/view_messagelist.go index 1bb6e1f..085809f 100644 --- a/cmd/cmdg/view_messagelist.go +++ b/cmd/cmdg/view_messagelist.go @@ -1,8 +1,13 @@ package main import ( + "bufio" + "bytes" "context" "fmt" + "io" + "os/exec" + "slices" "strings" "sync" "time" @@ -43,6 +48,7 @@ U — Mark marked mails as unread s, ^s — Search q — Quit ^L — Refresh screen +| — Pipe the selected messages to an external command in mbox format Press [enter] to exit ` @@ -990,6 +996,48 @@ func (mv *MessageView) Run(ctx context.Context) error { // stack frame on every navigation. return nv.Run(ctx) } + case "|": + cmds, err := dialog.Entry("Command> ", mv.keys) + if err == dialog.ErrAborted || cmds == "" { + // User aborted; do nothing. + break + } else if err != nil { + mv.errors <- errors.Wrap(err, "failed to get pipe command") + break + } + + xedmsgs := make([]*cmdg.Message, 0) + for _, msg := range mv.messages { + if marked[msg.ID] { + xedmsgs = append(xedmsgs, msg) + } + } + + if len(xedmsgs) == 0 { + log.Infof("No marked messages to do do operation %q on", "batch pipe") + break + } + + cmd := exec.CommandContext(ctx, *shell, "-c", cmds) + in, err := cmd.StdinPipe() + if err != nil { + // needz a showPager here too? + mv.errors <- errors.Wrap(err, "failed to setup pipe") + break + } + + go batchPipeHelper(ctx, xedmsgs, in, mv.errors) + + buf := new(bytes.Buffer) + cmd.Stdout = buf + cmd.Stderr = buf + if err := cmd.Run(); err != nil { + mv.errors <- errors.Wrapf(err, "failed run pipe command: %q", cmds) + break + } + if err := showPager(ctx, mv.keys, buf.String()); err != nil { + mv.errors <- showPager(ctx, mv.keys, buf.String()) + } case "q": return nil default: @@ -1032,3 +1080,51 @@ func (mv *MessageView) Run(ctx context.Context) error { log.Debugf("Draw took %v", time.Since(st)) } } + +func batchPipeHelper(ctx context.Context, xedmsgs []*cmdg.Message, in io.WriteCloser, ec chan error) { + + defer in.Close() + dest := bufio.NewWriter(in) + defer dest.Flush() + + // TODO(rjk): This is a lot of code. Pull into a function. + + // Messages need to be time-sorted to look like a mbox. + sorted := make([]SortableMessage, 0, len(xedmsgs)) + for _, m := range xedmsgs { + t, err := m.GetTime(ctx) + if err != nil { + ec <- errors.Wrap(err, "failed to get date of message") + return + } + from, err := m.GetFromAddress(ctx) + if err != nil { + ec <- errors.Wrap(err, "failed to get from of message") + return + } + sorted = append(sorted, SortableMessage{msg: m, t: t, from: from}) + } + slices.SortFunc(sorted, func(a, b SortableMessage) int { return a.t.Compare(b.t) }) + + for _, m := range sorted { + ms, err := m.msg.Raw(ctx) + if err != nil { + ec <- errors.Wrap(err, "failed to get raw message") + // Skip a message whose raw form cannot be read. + continue + } + + if err := printRFC822FromLine(dest, m); err != nil { + ec <- errors.Wrap(err, "failed to write RFC822 header") + // Give up if we can't write to the pipe. + return + } + + if _, err := dest.WriteString(ms); err != nil { + ec <- errors.Wrap(err, "failed to write raw email bodies to pipe") + // Give up if we can't write to the pipe. + return + } + } + +}