~sircmpwn/aerc

Add postpone command v2 PROPOSED

Jeffas: 4
 Add postpone command
 Add documentation for postpone
 Add edit command
 Add documentation for edit command

 9 files changed, 325 insertions(+), 5 deletions(-)
> I would rather not support writing to a file here, yet.
No problem, I can remove those parts.
> I also presume :edit is included for the purpose of recalling your
> drafts?
Yes, :edit was for recalling the selected message for editing.
Essentially meant for the drafts.
> I would prefer a dedicated :recall command and to address editing
> emails later, and somewhat differently.
Sure, should this still work with the currently selected message?
I think it should, then it keeps the drafts stored for easy backup and
allows the recall in any order.
> Can you pull these out of the patchset and simplify it a bit?
Sure, I'll take a look at this over the weekend.
> I would rather not support writing to a file here, yet. 
How do you want to handle it for notmuch then?
Notmuch doesn't have a concept of a directory and can't copy / append like imap et al
Regarding the recall from mail folders. Should recall only permit
recalling messages from the configured location? Otherwise it could be
used like a general "edit" still.
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~sircmpwn/aerc/patches/8310/mbox | git am -3
Learn more about email & git

[PATCH v2 1/4] Add postpone command Export this patch

This command uses the Postpone folder from the account config to save
messages to. Alternatively the directory to save to may be provided as
an argument to the command. Messages are saved as though they were sent
so have a valid 'to' recipient address and should be able to be read
back in for later editing.
---

Changed default postpone location to Drafts. Add postpone command to
binds.conf.

 commands/compose/postpone.go | 152 +++++++++++++++++++++++++++++++++++
 config/config.go             |   1 +
 widgets/compose.go           |   2 +-
 3 files changed, 154 insertions(+), 1 deletion(-)
 create mode 100644 commands/compose/postpone.go

diff --git a/commands/compose/postpone.go b/commands/compose/postpone.go
new file mode 100644
index 0000000..a5c6759
--- /dev/null
+++ b/commands/compose/postpone.go
@@ -0,0 +1,152 @@
package compose

import (
	"crypto/rand"
	"encoding/hex"
	"io"
	"io/ioutil"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/emersion/go-imap"
	"github.com/miolini/datacounter"
	"github.com/mitchellh/go-homedir"
	"github.com/pkg/errors"

	"git.sr.ht/~sircmpwn/aerc/commands"
	"git.sr.ht/~sircmpwn/aerc/widgets"
	"git.sr.ht/~sircmpwn/aerc/worker/types"
)

type Postpone struct{}

func init() {
	register(Postpone{})
}

func (Postpone) Aliases() []string {
	return []string{"postpone"}
}

func (Postpone) Complete(aerc *widgets.Aerc, args []string) []string {
	path := strings.Join(args, " ")
	return commands.CompletePath(path)
}

func (Postpone) Execute(aerc *widgets.Aerc, args []string) error {
	var postponeLocation string
	if len(args) > 1 {
		postponeLocation = strings.Join(args[1:], " ")
	}
	composer, _ := aerc.SelectedTab().(*widgets.Composer)
	config := composer.Config()

	if postponeLocation == "" {
		postponeLocation = config.Postpone
	}
	if postponeLocation == "" {
		return errors.New("No Postpone location configured")
	}

	aerc.Logger().Println("Postponing mail")

	header, _, err := composer.PrepareHeader()
	if err != nil {
		return errors.Wrap(err, "PrepareHeader")
	}

	// if the postpone location begins with a ~ or / then it is a raw filepath
	if strings.HasPrefix(postponeLocation, "~") || filepath.IsAbs(postponeLocation) {
		// make the directory
		dir, err := homedir.Expand(postponeLocation)
		if err != nil {
			return errors.Wrap(err, "Postpone homedir")
		}
		err = os.MkdirAll(dir, 0755)
		if err != nil {
			return errors.Wrap(err, "Postpone mkdir")
		}
		// generate a new filename
		filename := strconv.FormatInt(time.Now().Unix(), 10)
		filename += "."
		bs := make([]byte, 10)
		_, err = io.ReadFull(rand.Reader, bs)
		if err != nil {
			return errors.Wrap(err, "Postpone filename")
		}
		filename += hex.EncodeToString(bs)
		// create the file and write the content to it
		file, err := os.Create(path.Join(dir, filename))
		if err != nil {
			return errors.Wrap(err, "Postpone create file")
		}
		aerc.RemoveTab(composer)
		header, _, _ = composer.PrepareHeader()
		composer.WriteMessage(header, file)
		err = file.Close()
		if err != nil {
			aerc.PushError(" " + err.Error())
			composer.Close()
			return errors.Wrap(err, "Postpone close file")
		}
		aerc.PushStatus("Message postponed.", 10*time.Second)
		composer.Close()
	} else /* the postpone location refers to a dir in the account */ {
		worker := composer.Worker()
		// to synchronise the creating of the directory
		errChan := make(chan string)
		worker.PostAction(&types.CreateDirectory{
			Directory: postponeLocation,
		}, func(msg types.WorkerMessage) {
			switch msg := msg.(type) {
			case *types.Done:
				errChan <- ""
			case *types.Error:
				errChan <- msg.Error.Error()
			}
		})

		// run this as a goroutine so we can make other progress. The message
		// will be saved once the directory is created.
		go func() {
			errStr := <-errChan
			if errStr != "" {
				aerc.PushError(" " + errStr)
				return
			}

			aerc.RemoveTab(composer)

			ctr := datacounter.NewWriterCounter(ioutil.Discard)
			composer.WriteMessage(header, ctr)
			nbytes := int(ctr.Count())
			r, w := io.Pipe()
			worker.PostAction(&types.AppendMessage{
				Destination: postponeLocation,
				Flags:       []string{imap.SeenFlag},
				Date:        time.Now(),
				Reader:      r,
				Length:      nbytes,
			}, func(msg types.WorkerMessage) {
				switch msg := msg.(type) {
				case *types.Done:
					aerc.PushStatus("Message postponed.", 10*time.Second)
					r.Close()
					composer.Close()
				case *types.Error:
					aerc.PushError(" " + msg.Error.Error())
					r.Close()
					composer.Close()
				}
			})
			header, _, _ = composer.PrepareHeader()
			composer.WriteMessage(header, w)
			w.Close()
		}()
	}
	return nil
}
diff --git a/config/config.go b/config/config.go
index eeaf937..76b9be7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -47,6 +47,7 @@ type AccountConfig struct {
	Archive         string
	CopyTo          string
	Default         string
	Postpone        string
	From            string
	Name            string
	Source          string
diff --git a/widgets/compose.go b/widgets/compose.go
index 22c58da..3915309 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -742,7 +742,7 @@ func newReviewMessage(composer *Composer, err error) *reviewMessage {
	} else {
		// TODO: source this from actual keybindings?
		grid.AddChild(ui.NewText(
			"Send this email? [y]es/[n]o/[e]dit/[a]ttach")).At(0, 0)
			"Send this email? [y]es/[n]o/[p]ostpone/[e]dit/[a]ttach")).At(0, 0)
		grid.AddChild(ui.NewText("Attachments:").
			Reverse(true)).At(1, 0)
		if len(composer.attachments) == 0 {
--
2.23.0
I would rather not support writing to a file here, yet. I also presume
:edit is included for the purpose of recalling your drafts? I would
prefer a dedicated :recall command and to address editing emails later,
and somewhat differently. Can you pull these out of the patchset and
simplify it a bit?

[PATCH v2 2/4] Add documentation for postpone Export this patch

Adds docs for the postpone command and the Postpone accounts.conf config
option. A default for the postpone config option is provided of: Drafts.
---
 config/binds.conf     | 1 +
 config/config.go      | 9 +++++----
 doc/aerc-config.5.scd | 5 +++++
 doc/aerc.1.scd        | 5 +++++
 4 files changed, 16 insertions(+), 4 deletions(-)

diff --git a/config/binds.conf b/config/binds.conf
index ac49bd0..ea6b99e 100644
--- a/config/binds.conf
+++ b/config/binds.conf
@@ -87,6 +87,7 @@ $ex = <C-x>
# Keybindings used when reviewing a message to be sent
y = :send<Enter>
n = :abort<Enter>
p = :postpone<Enter>
q = :abort<Enter>
e = :edit<Enter>
a = :attach<space>
diff --git a/config/config.go b/config/config.go
index 76b9be7..2683ce8 100644
--- a/config/config.go
+++ b/config/config.go
@@ -139,10 +139,11 @@ func loadAccountConfig(path string) ([]AccountConfig, error) {
		}
		sec := file.Section(_sec)
		account := AccountConfig{
			Archive: "Archive",
			Default: "INBOX",
			Name:    _sec,
			Params:  make(map[string]string),
			Archive:  "Archive",
			Default:  "INBOX",
			Postpone: "Drafts",
			Name:     _sec,
			Params:   make(map[string]string),
		}
		if err = sec.MapTo(&account); err != nil {
			return nil, err
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 91b444a..fd80d3a 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -281,6 +281,11 @@ Note that many of these configuration options are written for you, such as

	Default: none

*postpone*
	Specifies the folder to save *postpone*d messages to.

	Default: Drafts

*source*
	Specifies the source for reading incoming emails on this account. This key
	is required for all accounts. It should be a connection string, and the
diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd
index 2ec17a4..e89f5e7 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -243,6 +243,11 @@ message list, the message in the message viewer, etc).
*next-field*, *prev-field*
	Cycles between input fields in the compose window.

*postpone* [directory]
	Saves the current state of the message to the *postpone* folder for the
	current account. If *directory* is provided then the message is saved
	to this directory with a uniquely generated name.

*save* [-p] <path>
	Saves the selected message part to the specified path. If -p is selected,
	aerc will create any missing directories in the specified path. If the path
-- 
2.23.0

[PATCH v2 3/4] Add edit command Export this patch

This command allows editing any email in the composer. By default the
command uses the selected message to edit again but can optionally take
a filepath and use this to read the email from.
---
 commands/msg/edit.go | 151 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 151 insertions(+)
 create mode 100644 commands/msg/edit.go

diff --git a/commands/msg/edit.go b/commands/msg/edit.go
new file mode 100644
index 0000000..727d941
--- /dev/null
+++ b/commands/msg/edit.go
@@ -0,0 +1,151 @@
package msg

import (
	"bytes"
	"io"
	"io/ioutil"
	"os"
	"strings"

	"github.com/emersion/go-message"
	_ "github.com/emersion/go-message/charset"
	"github.com/emersion/go-message/mail"
	"github.com/pkg/errors"

	"git.sr.ht/~sircmpwn/aerc/commands"
	"git.sr.ht/~sircmpwn/aerc/models"
	"git.sr.ht/~sircmpwn/aerc/widgets"
	"git.sr.ht/~sircmpwn/aerc/worker/lib"
)

type edit struct{}

func init() {
	register(edit{})
}

func (edit) Aliases() []string {
	return []string{"edit"}
}

func (edit) Complete(aerc *widgets.Aerc, args []string) []string {
	path := strings.Join(args, " ")
	return commands.CompletePath(path)
}

func (edit) Execute(aerc *widgets.Aerc, args []string) error {
	var msgPath string
	if len(args) > 1 {
		msgPath = strings.Join(args[1:], " ")
	}

	widget := aerc.SelectedTab().(widgets.ProvidesMessage)
	acct := widget.SelectedAccount()
	if acct == nil {
		return errors.New("No account selected")
	}
	store := widget.Store()
	if store == nil {
		return errors.New("Cannot perform action. Messages still loading")
	}

	var msgInfo *models.MessageInfo
	var err error
	if len(msgPath) != 0 {
		// read message from filesystem instead of currently selected dir
		msgInfo, err = lib.MessageInfo(fileMessage(msgPath))
	} else {
		msgInfo, err = widget.SelectedMessage()
	}
	if err != nil {
		return errors.Wrap(err, "Edit failed")
	}
	acct.Logger().Println("Editing message " + msgInfo.Envelope.MessageId)

	// copy the headers to the defaults map for addition to the composition
	defaults := make(map[string]string)
	headerFields := msgInfo.RFC822Headers.Fields()
	for headerFields.Next() {
		defaults[headerFields.Key()] = headerFields.Value()
	}

	composer := widgets.NewComposer(aerc, aerc.Config(),
		acct.AccountConfig(), acct.Worker(), defaults)

	// focus the terminal since the header fields are likely already done
	composer.FocusTerminal()

	addTab := func() {
		tab := aerc.NewTab(composer, msgInfo.Envelope.Subject)
		composer.OnHeaderChange("Subject", func(subject string) {
			if subject == "" {
				tab.Name = "New email"
			} else {
				tab.Name = subject
			}
			tab.Content.Invalidate()
		})
	}

	// find the main body part and add it to the editor
	// TODO: copy all parts of the message over?
	var (
		path []int
		part *models.BodyStructure
	)
	if len(msgInfo.BodyStructure.Parts) != 0 {
		part, path = findPlaintext(msgInfo.BodyStructure, path)
	}
	if part == nil {
		part = msgInfo.BodyStructure
		path = []int{1}
	}

	store.FetchBodyPart(msgInfo.Uid, path, func(reader io.Reader) {
		header := message.Header{}
		header.SetText(
			"Content-Transfer-Encoding", part.Encoding)
		header.SetContentType(part.MIMEType, part.Params)
		header.SetText("Content-Description", part.Description)
		entity, err := message.New(header, reader)
		if err != nil {
			// TODO: Do something with the error
			addTab()
			return
		}
		mreader := mail.NewReader(entity)
		part, err := mreader.NextPart()
		if err != nil {
			// TODO: Do something with the error
			addTab()
			return
		}
		composer.SetContents(part.Body)
		addTab()
	})

	return nil
}

type fileMessage string

func (m fileMessage) NewReader() (io.Reader, error) {
	file, err := os.Open(string(m))
	if err != nil {
		return nil, err
	}
	defer file.Close()
	b, err := ioutil.ReadAll(file)
	if err != nil {
		return nil, err
	}
	return bytes.NewReader(b), nil
}

func (m fileMessage) ModelFlags() ([]models.Flag, error) {
	return nil, nil
}

func (m fileMessage) UID() uint32 {
	return 0
}
-- 
2.23.0

[PATCH v2 4/4] Add documentation for edit command Export this patch

---
 doc/aerc.1.scd | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd
index e89f5e7..05e23f6 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -96,6 +96,10 @@ message list, the message in the message viewer, etc).
*delete*
	Deletes the selected message.

*edit* [filename]
	Opens the selected message for editing. If *filename* is provided then
	it attempts to open the message from here.

*forward* [-A] [address...]
	Opens the composer to forward the selected message to another recipient.

-- 
2.23.0
View this thread in the archives