~sircmpwn/aerc

Add postpone command v2 PROPOSED

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?
> 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
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/%3C20190920120236.939662-1-dev%40jeffas.io%3E/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