~sircmpwn/aerc

Add postpone command v4 PROPOSED

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/%3C20191008135144.439888-1-dev%40jeffas.io%3E/mbox | git am -3
Learn more about email & git

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

This command uses the Postpone folder from the account config to save
messages to. 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.
---

This revision checks the list of directories for an existing one and
only creates a new one if not present.

 commands/account/compose.go  |   2 +-
 commands/compose/postpone.go | 113 +++++++++++++++++++++++++++++++++++
 commands/msg/forward.go      |   2 +-
 commands/msg/reply.go        |   2 +-
 commands/msg/unsubscribe.go  |   1 +
 config/config.go             |   1 +
 widgets/aerc.go              |   4 +-
 widgets/compose.go           |  42 +++++++------
 8 files changed, 145 insertions(+), 22 deletions(-)
 create mode 100644 commands/compose/postpone.go

diff --git a/commands/account/compose.go b/commands/account/compose.go
index 039eb92..b1c3ddd 100644
--- a/commands/account/compose.go
+++ b/commands/account/compose.go
@@ -29,7 +29,7 @@ func (Compose) Execute(aerc *widgets.Aerc, args []string) error {
 		return err
 	}
 	acct := aerc.SelectedAccount()
-	composer := widgets.NewComposer(aerc,
+	composer := widgets.NewComposer(aerc, acct,
 		aerc.Config(), acct.AccountConfig(), acct.Worker(), nil)
 	tab := aerc.NewTab(composer, "New email")
 	composer.OnHeaderChange("Subject", func(subject string) {
diff --git a/commands/compose/postpone.go b/commands/compose/postpone.go
new file mode 100644
index 0000000..45ed764
--- /dev/null
+++ b/commands/compose/postpone.go
@@ -0,0 +1,113 @@
+package compose
+
+import (
+	"io"
+	"io/ioutil"
+	"time"
+
+	"github.com/emersion/go-imap"
+	"github.com/miolini/datacounter"
+	"github.com/pkg/errors"
+
+	"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 {
+	return nil
+}
+
+func (Postpone) Execute(aerc *widgets.Aerc, args []string) error {
+	if len(args) != 1 {
+		return errors.New("Usage: postpone")
+	}
+	composer, _ := aerc.SelectedTab().(*widgets.Composer)
+	config := composer.Config()
+
+	if config.Postpone == "" {
+		return errors.New("No Postpone location configured")
+	}
+
+	aerc.Logger().Println("Postponing mail")
+
+	header, _, err := composer.PrepareHeader()
+	if err != nil {
+		return errors.Wrap(err, "PrepareHeader")
+	}
+	worker := composer.Worker()
+	dirs := aerc.SelectedAccount().Directories().List()
+	alreadyCreated := false
+	for _, dir := range dirs {
+		if dir == config.Postpone {
+			alreadyCreated = true
+			break
+		}
+	}
+
+	errChan := make(chan string)
+
+	// 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: config.Postpone,
+			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()
+			}
+		})
+		composer.WriteMessage(header, w)
+		w.Close()
+	}()
+
+	if !alreadyCreated {
+		// to synchronise the creating of the directory
+		worker.PostAction(&types.CreateDirectory{
+			Directory: config.Postpone,
+		}, func(msg types.WorkerMessage) {
+			switch msg := msg.(type) {
+			case *types.Done:
+				errChan <- ""
+			case *types.Error:
+				errChan <- msg.Error.Error()
+			}
+		})
+	} else {
+		errChan <- ""
+	}
+
+	return nil
+}
diff --git a/commands/msg/forward.go b/commands/msg/forward.go
index 494072d..5008952 100644
--- a/commands/msg/forward.go
+++ b/commands/msg/forward.go
@@ -69,7 +69,7 @@ func (forward) Execute(aerc *widgets.Aerc, args []string) error {
 		"To":      to,
 		"Subject": subject,
 	}
-	composer := widgets.NewComposer(aerc, aerc.Config(), acct.AccountConfig(),
+	composer := widgets.NewComposer(aerc, acct, aerc.Config(), acct.AccountConfig(),
 		acct.Worker(), defaults)

 	addTab := func() {
diff --git a/commands/msg/reply.go b/commands/msg/reply.go
index 9ef7a3b..35de2ab 100644
--- a/commands/msg/reply.go
+++ b/commands/msg/reply.go
@@ -116,7 +116,7 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
 		"In-Reply-To": msg.Envelope.MessageId,
 	}

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

 	if args[0] == "reply" {
diff --git a/commands/msg/unsubscribe.go b/commands/msg/unsubscribe.go
index 15a9411..8d13dc1 100644
--- a/commands/msg/unsubscribe.go
+++ b/commands/msg/unsubscribe.go
@@ -89,6 +89,7 @@ func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL) error {
 	}
 	composer := widgets.NewComposer(
 		aerc,
+		acct,
 		aerc.Config(),
 		acct.AccountConfig(),
 		acct.Worker(),
diff --git a/config/config.go b/config/config.go
index 133a7f4..d639ee4 100644
--- a/config/config.go
+++ b/config/config.go
@@ -49,6 +49,7 @@ type AccountConfig struct {
 	Archive         string
 	CopyTo          string
 	Default         string
+	Postpone        string
 	From            string
 	Name            string
 	Source          string
diff --git a/widgets/aerc.go b/widgets/aerc.go
index af51a0f..660a7c6 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -274,6 +274,8 @@ func (aerc *Aerc) SelectedAccount() *AccountView {
 		return tab
 	case *MessageViewer:
 		return tab.SelectedAccount()
+	case *Composer:
+		return tab.Account()
 	}
 	return nil
 }
@@ -431,7 +433,7 @@ func (aerc *Aerc) Mailto(addr *url.URL) error {
 			defaults[header] = strings.Join(vals, ",")
 		}
 	}
-	composer := NewComposer(aerc, aerc.Config(),
+	composer := NewComposer(aerc, acct, aerc.Config(),
 		acct.AccountConfig(), acct.Worker(), defaults)
 	composer.FocusSubject()
 	title := "New email"
diff --git a/widgets/compose.go b/widgets/compose.go
index 22c58da..8ae6da6 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -30,9 +30,10 @@ import (
 type Composer struct {
 	editors map[string]*headerEditor

-	acct   *config.AccountConfig
-	config *config.AercConfig
-	aerc   *Aerc
+	acctConfig *config.AccountConfig
+	config     *config.AercConfig
+	acct       *AccountView
+	aerc       *Aerc

 	defaults    map[string]string
 	editor      *Terminal
@@ -52,14 +53,14 @@ type Composer struct {
 	width int
 }

-func NewComposer(aerc *Aerc, conf *config.AercConfig,
-	acct *config.AccountConfig, worker *types.Worker, defaults map[string]string) *Composer {
+func NewComposer(aerc *Aerc, acct *AccountView, conf *config.AercConfig,
+	acctConfig *config.AccountConfig, worker *types.Worker, defaults map[string]string) *Composer {

 	if defaults == nil {
 		defaults = make(map[string]string)
 	}
 	if from := defaults["From"]; from == "" {
-		defaults["From"] = acct.From
+		defaults["From"] = acctConfig.From
 	}

 	layout, editors, focusable := buildComposeHeader(
@@ -72,14 +73,15 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
 	}

 	c := &Composer{
-		aerc:     aerc,
-		editors:  editors,
-		acct:     acct,
-		config:   conf,
-		defaults: defaults,
-		email:    email,
-		worker:   worker,
-		layout:   layout,
+		aerc:       aerc,
+		acct:       acct,
+		editors:    editors,
+		acctConfig: acctConfig,
+		config:     conf,
+		defaults:   defaults,
+		email:      email,
+		worker:     worker,
+		layout:     layout,
 		// You have to backtab to get to "From", since you usually don't edit it
 		focused:   1,
 		focusable: focusable,
@@ -165,7 +167,7 @@ func (c *Composer) AppendContents(reader io.Reader) {

 func (c *Composer) AddSignature() {
 	var signature []byte
-	if c.acct.SignatureCmd != "" {
+	if c.acctConfig.SignatureCmd != "" {
 		var err error
 		signature, err = c.readSignatureFromCmd()
 		if err != nil {
@@ -178,7 +180,7 @@ func (c *Composer) AddSignature() {
 }

 func (c *Composer) readSignatureFromCmd() ([]byte, error) {
-	sigCmd := c.acct.SignatureCmd
+	sigCmd := c.acctConfig.SignatureCmd
 	cmd := exec.Command("sh", "-c", sigCmd)
 	signature, err := cmd.Output()
 	if err != nil {
@@ -188,7 +190,7 @@ func (c *Composer) readSignatureFromCmd() ([]byte, error) {
 }

 func (c *Composer) readSignatureFromFile() []byte {
-	sigFile := c.acct.SignatureFile
+	sigFile := c.acctConfig.SignatureFile
 	if sigFile == "" {
 		return nil
 	}
@@ -294,6 +296,10 @@ func (c *Composer) Focus(focus bool) {
 }

 func (c *Composer) Config() *config.AccountConfig {
+	return c.acctConfig
+}
+
+func (c *Composer) Account() *AccountView {
 	return c.acct
 }

@@ -742,7 +748,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
Thanks! I still have two issues with this.

1. The docs should be added together with the feature in the same
   commit - please rebase.
2. Using this command causes my IMAP connection to immediately EOF.

[PATCH v4 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        | 4 ++++
 4 files changed, 15 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 d639ee4..be021c7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -141,10 +141,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 f4f02f2..e753b99 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -295,6 +295,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 e76b519..f534f05 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -268,6 +268,10 @@ message list, the message in the message viewer, etc).
 *next-field*, *prev-field*
 	Cycles between input fields in the compose window.
 
+*postpone*
+	Saves the current state of the message to the *postpone* folder for the
+	current account.
+
 *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 v4 3/4] Add recall command Export this patch

This command allows recalling the selected postponed email to edit in
the composer. The command only allows recalling from the postpone
directory.
---
 commands/msg/recall.go | 121 +++++++++++++++++++++++++++++++++++++++++
 widgets/account.go     |   4 ++
 2 files changed, 125 insertions(+)
 create mode 100644 commands/msg/recall.go

diff --git a/commands/msg/recall.go b/commands/msg/recall.go
new file mode 100644
index 0000000..116d489
--- /dev/null
+++ b/commands/msg/recall.go
@@ -0,0 +1,121 @@
+package msg
+
+import (
+	"io"
+
+	"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/models"
+	"git.sr.ht/~sircmpwn/aerc/widgets"
+)
+
+type Recall struct{}
+
+func init() {
+	register(Recall{})
+}
+
+func (Recall) Aliases() []string {
+	return []string{"recall"}
+}
+
+func (Recall) Complete(aerc *widgets.Aerc, args []string) []string {
+	return nil
+}
+
+func (Recall) Execute(aerc *widgets.Aerc, args []string) error {
+	if len(args) != 1 {
+		return errors.New("Usage: recall")
+	}
+
+	widget := aerc.SelectedTab().(widgets.ProvidesMessage)
+	acct := widget.SelectedAccount()
+	if acct == nil {
+		return errors.New("No account selected")
+	}
+	if acct.SelectedDirectory() != acct.AccountConfig().Postpone {
+		return errors.New("Can only recall from the postpone directory: " +
+			acct.AccountConfig().Postpone)
+	}
+	store := widget.Store()
+	if store == nil {
+		return errors.New("Cannot perform action. Messages still loading")
+	}
+
+	msgInfo, err := widget.SelectedMessage()
+	if err != nil {
+		return errors.Wrap(err, "Recall failed")
+	}
+	acct.Logger().Println("Recalling 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, acct, aerc.Config(),
+		acct.AccountConfig(), acct.Worker(), defaults)
+
+	// focus the terminal since the header fields are likely already done
+	composer.FocusTerminal()
+
+	addTab := func() {
+		subject := msgInfo.Envelope.Subject
+		if subject == "" {
+			subject = "Recalled email"
+		}
+		tab := aerc.NewTab(composer, 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
+}
diff --git a/widgets/account.go b/widgets/account.go
index 4e8dd17..213f1a6 100644
--- a/widgets/account.go
+++ b/widgets/account.go
@@ -182,6 +182,10 @@ func (acct *AccountView) SelectedAccount() *AccountView {
 	return acct
 }
 
+func (acct *AccountView) SelectedDirectory() string {
+	return acct.dirlist.Selected()
+}
+
 func (acct *AccountView) SelectedMessage() (*models.MessageInfo, error) {
 	if len(acct.msglist.Store().Uids()) == 0 {
 		return nil, errors.New("no message selected")
-- 
2.23.0

[PATCH v4 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 f534f05..cbe8e1f 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.

+*recall*
+	Opens the selected message for re-editing. Messages can only be
+	recalled from the postpone directory.
+
 *forward* [-A] [address...]
 	Opens the composer to forward the selected message to another recipient.

--
2.23.0
View this thread in the archives