~sircmpwn/aerc

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

[PATCH v2] WIP: Add Templates with Parsing

Details
Message ID
<20191017182923.1119304-1-sri@vathsan.com>
DKIM signature
pass
Download raw message
Patch: +255 -64
+ Changes NewComposer to return error.
+ Add lib to handle templates using "text/template".
+ Add -T option to account/compose.
+ Quoted replies using templates.
	- Add default template for quoted_replies.
	- [WIP] Get Original message from message parts.
+ Default templates are installed similar to filters.
+ Templates Config in aerc.conf.
	- Required templates are parsed while loading config.
---
Made some changes since the last patch.
Still ironing things out. I am having some trouble with 
the reply command.

I moved the quoted part above the NewComposer call to send the original
data as part of the defaults.
But unfortunately, `msg.BodyStructure.Parts` in commands/msg/reply.go is empty.
Hence, no plain text is found. Any idea why that would be the case?

 Makefile                    |   3 +-
 commands/account/compose.go |  24 +++++---
 commands/msg/forward.go     |   7 ++-
 commands/msg/reply.go       |  66 ++++++++++------------
 commands/msg/unsubscribe.go |   6 +-
 config/aerc.conf.in         |  16 +++++-
 config/config.go            |  41 +++++++++++---
 lib/templates/template.go   | 110 ++++++++++++++++++++++++++++++++++++
 templates/quoted_reply      |   2 +
 widgets/aerc.go             |   7 ++-
 widgets/compose.go          |  37 +++++++++++-
 11 files changed, 255 insertions(+), 64 deletions(-)
 create mode 100644 lib/templates/template.go
 create mode 100644 templates/quoted_reply

diff --git a/Makefile b/Makefile
index c2e6d1ba4a97..4973e331593f 100644
--- a/Makefile
+++ b/Makefile
@@ -58,7 +58,7 @@ clean:
 
 install: all
 	mkdir -p $(BINDIR) $(MANDIR)/man1 $(MANDIR)/man5 $(MANDIR)/man7 \
-		$(SHAREDIR) $(SHAREDIR)/filters
+		$(SHAREDIR) $(SHAREDIR)/filters $(SHAREDIR)/templates
 	install -m755 aerc $(BINDIR)/aerc
 	install -m644 aerc.1 $(MANDIR)/man1/aerc.1
 	install -m644 aerc-search.1 $(MANDIR)/man1/aerc-search.1
@@ -75,6 +75,7 @@ install: all
 	install -m755 filters/hldiff $(SHAREDIR)/filters/hldiff
 	install -m755 filters/html $(SHAREDIR)/filters/html
 	install -m755 filters/plaintext $(SHAREDIR)/filters/plaintext
+	install -m644 templates/quote_reply $(SHAREDIR)/templates/quote_reply
 
 RMDIR_IF_EMPTY:=sh -c '\
 if test -d $$0 && ! ls -1qA $$0 | grep -q . ; then \
diff --git a/commands/account/compose.go b/commands/account/compose.go
index 039eb92363c6..24e460b318b1 100644
--- a/commands/account/compose.go
+++ b/commands/account/compose.go
@@ -24,13 +24,17 @@ func (Compose) Complete(aerc *widgets.Aerc, args []string) []string {
 }
 
 func (Compose) Execute(aerc *widgets.Aerc, args []string) error {
-	body, err := buildBody(args)
+	body, template, err := buildBody(args)
 	if err != nil {
 		return err
 	}
 	acct := aerc.SelectedAccount()
-	composer := widgets.NewComposer(aerc,
-		aerc.Config(), acct.AccountConfig(), acct.Worker(), nil)
+
+	composer, err := widgets.NewComposer(aerc,
+		aerc.Config(), acct.AccountConfig(), acct.Worker(), template, nil)
+	if err != nil {
+		return err
+	}
 	tab := aerc.NewTab(composer, "New email")
 	composer.OnHeaderChange("Subject", func(subject string) {
 		if subject == "" {
@@ -44,11 +48,11 @@ func (Compose) Execute(aerc *widgets.Aerc, args []string) error {
 	return nil
 }
 
-func buildBody(args []string) (string, error) {
-	var body, headers string
-	opts, optind, err := getopt.Getopts(args, "H:")
+func buildBody(args []string) (string, string, error) {
+	var body, template, headers string
+	opts, optind, err := getopt.Getopts(args, "H:T:")
 	if err != nil {
-		return "", err
+		return "", "", err
 	}
 	for _, opt := range opts {
 		switch opt.Option {
@@ -60,11 +64,13 @@ func buildBody(args []string) (string, error) {
 			} else {
 				headers += opt.Value + ":\n"
 			}
+		case 'T':
+			template = opt.Value
 		}
 	}
 	posargs := args[optind:]
 	if len(posargs) > 1 {
-		return "", errors.New("Usage: compose [-H] [body]")
+		return "", template, errors.New("Usage: compose [-H] [body]")
 	}
 	if len(posargs) == 1 {
 		body = posargs[0]
@@ -76,5 +82,5 @@ func buildBody(args []string) (string, error) {
 			body = headers + "\n\n"
 		}
 	}
-	return body, nil
+	return body, template, nil
 }
diff --git a/commands/msg/forward.go b/commands/msg/forward.go
index 494072d07d04..19dd1b6e801d 100644
--- a/commands/msg/forward.go
+++ b/commands/msg/forward.go
@@ -69,8 +69,11 @@ func (forward) Execute(aerc *widgets.Aerc, args []string) error {
 		"To":      to,
 		"Subject": subject,
 	}
-	composer := widgets.NewComposer(aerc, aerc.Config(), acct.AccountConfig(),
-		acct.Worker(), defaults)
+	composer, err := widgets.NewComposer(aerc, aerc.Config(), acct.AccountConfig(),
+		acct.Worker(), "", defaults)
+	if err != nil {
+		return err
+	}
 
 	addTab := func() {
 		tab := aerc.NewTab(composer, subject)
diff --git a/commands/msg/reply.go b/commands/msg/reply.go
index 9ef7a3b82206..c73ae8533d8e 100644
--- a/commands/msg/reply.go
+++ b/commands/msg/reply.go
@@ -1,7 +1,7 @@
 package msg
 
 import (
-	"bufio"
+	"bytes"
 	"errors"
 	"fmt"
 	"io"
@@ -116,26 +116,10 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
 		"In-Reply-To": msg.Envelope.MessageId,
 	}
 
-	composer := widgets.NewComposer(aerc, aerc.Config(),
-		acct.AccountConfig(), acct.Worker(), defaults)
-
-	if args[0] == "reply" {
-		composer.FocusTerminal()
-	}
-
-	addTab := func() {
-		tab := aerc.NewTab(composer, subject)
-		composer.OnHeaderChange("Subject", func(subject string) {
-			if subject == "" {
-				tab.Name = "New email"
-			} else {
-				tab.Name = subject
-			}
-			tab.Content.Invalidate()
-		})
-	}
+	var template string
 
 	if quote {
+		template = aerc.Config().Templates.QuotedReply
 		var (
 			path []int
 			part *models.BodyStructure
@@ -147,6 +131,8 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
 			part = msg.BodyStructure
 			path = []int{1}
 		}
+		defaults["OriginalFrom"] = msg.Envelope.From[0].Name
+		defaults["OriginalDate"] = msg.Envelope.Date.Format("Mon Jan 2, 2006 at 3:04 PM")
 
 		store.FetchBodyPart(msg.Uid, path, func(reader io.Reader) {
 			header := message.Header{}
@@ -157,35 +143,43 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
 			entity, err := message.New(header, reader)
 			if err != nil {
 				// TODO: Do something with the error
-				addTab()
+				// addTab()
 				return
 			}
 			mreader := mail.NewReader(entity)
 			part, err := mreader.NextPart()
 			if err != nil {
 				// TODO: Do something with the error
-				addTab()
+				// addTab()
 				return
 			}
 
-			pipeout, pipein := io.Pipe()
-			scanner := bufio.NewScanner(part.Body)
-			go composer.PrependContents(pipeout)
-			// TODO: Let user customize the date format used here
-			io.WriteString(pipein, fmt.Sprintf("On %s %s wrote:\n",
-				msg.Envelope.Date.Format("Mon Jan 2, 2006 at 3:04 PM"),
-				msg.Envelope.From[0].Name))
-			for scanner.Scan() {
-				io.WriteString(pipein, fmt.Sprintf("> %s\n", scanner.Text()))
-			}
-			pipein.Close()
-			pipeout.Close()
-			addTab()
+			buf := new(bytes.Buffer)
+			buf.ReadFrom(part.Body)
+			defaults["Original"] = buf.String()
 		})
-	} else {
-		addTab()
 	}
 
+	composer, err := widgets.NewComposer(aerc, aerc.Config(),
+		acct.AccountConfig(), acct.Worker(), template, defaults)
+	if err != nil {
+		return err
+	}
+
+	if args[0] == "reply" {
+		composer.FocusTerminal()
+	}
+
+	tab := aerc.NewTab(composer, subject)
+	composer.OnHeaderChange("Subject", func(subject string) {
+		if subject == "" {
+			tab.Name = "New email"
+		} else {
+			tab.Name = subject
+		}
+		tab.Content.Invalidate()
+	})
+
 	return nil
 }
 
diff --git a/commands/msg/unsubscribe.go b/commands/msg/unsubscribe.go
index 15a9411c9c8f..5ffec4652056 100644
--- a/commands/msg/unsubscribe.go
+++ b/commands/msg/unsubscribe.go
@@ -87,13 +87,17 @@ func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL) error {
 		"To":      u.Opaque,
 		"Subject": u.Query().Get("subject"),
 	}
-	composer := widgets.NewComposer(
+	composer, err := widgets.NewComposer(
 		aerc,
 		aerc.Config(),
 		acct.AccountConfig(),
 		acct.Worker(),
+		"",
 		defaults,
 	)
+	if err != nil {
+		return err
+	}
 	composer.SetContents(strings.NewReader(u.Query().Get("body")))
 	tab := aerc.NewTab(composer, "unsubscribe")
 	composer.OnHeaderChange("Subject", func(subject string) {
diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index ec89ff7e9d26..a982d4be633e 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -108,7 +108,7 @@ editor=
 
 #
 # Default header fields to display when composing a message. To display
-# multiple headers in the same row, separate them with a pipe, e.g. "To|From". 
+# multiple headers in the same row, separate them with a pipe, e.g. "To|From".
 #
 # Default: To|From,Subject
 header-layout=To|From,Subject
@@ -139,3 +139,17 @@ text/*=awk -f @SHAREDIR@/filters/plaintext
 #
 # Executed when a new email arrives in the selected folder
 new-email=
+
+[templates]
+# Templates are used to populate email bodies automatically.
+#
+
+# The directory where the template are stored.
+#
+# default: @SHAREDIR@/templates/
+template-dir=@SHAREDIR@/templates/
+
+# The template to be used for quoted replies.
+#
+# default: quoted_reply
+quoted-reply=quoted_reply
diff --git a/config/config.go b/config/config.go
index 133a7f4ed5ca..635a5b342ae4 100644
--- a/config/config.go
+++ b/config/config.go
@@ -16,6 +16,8 @@ import (
 	"github.com/gdamore/tcell"
 	"github.com/go-ini/ini"
 	"github.com/kyoh86/xdg"
+
+	"git.sr.ht/~sircmpwn/aerc/lib/templates"
 )
 
 type GeneralConfig struct {
@@ -98,16 +100,22 @@ type TriggersConfig struct {
 	ExecuteCommand func(command []string) error
 }
 
+type TemplateConfig struct {
+	TemplateDir  string `ini:"template-dir"`
+	QuotedReply  string `ini:"quoted-reply"`
+}
+
 type AercConfig struct {
-	Bindings BindingConfig
-	Compose  ComposeConfig
-	Ini      *ini.File       `ini:"-"`
-	Accounts []AccountConfig `ini:"-"`
-	Filters  []FilterConfig  `ini:"-"`
-	Viewer   ViewerConfig    `ini:"-"`
-	Triggers TriggersConfig  `ini:"-"`
-	Ui       UIConfig
-	General  GeneralConfig
+	Bindings  BindingConfig
+	Compose   ComposeConfig
+	Ini       *ini.File       `ini:"-"`
+	Accounts  []AccountConfig `ini:"-"`
+	Filters   []FilterConfig  `ini:"-"`
+	Viewer    ViewerConfig    `ini:"-"`
+	Triggers  TriggersConfig  `ini:"-"`
+	Ui        UIConfig
+	General   GeneralConfig
+	Templates TemplateConfig
 }
 
 // Input: TimestampFormat
@@ -305,6 +313,21 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
 			return err
 		}
 	}
+	if templatesSec, err := file.GetSection("templates"); err == nil {
+		if err := templatesSec.MapTo(&config.Templates); err != nil {
+			return err
+		}
+		for key, val := range templatesSec.KeysHash() {
+			if key == "template-dir" {
+				continue
+			}
+			_, err := templates.ParseTemplateFromFile(
+				val, config.Templates.TemplateDir, templates.ParseTemplateData(nil))
+			if err != nil {
+				return err
+			}
+		}
+	}
 	return nil
 }
 
diff --git a/lib/templates/template.go b/lib/templates/template.go
new file mode 100644
index 000000000000..2dce1d0274d0
--- /dev/null
+++ b/lib/templates/template.go
@@ -0,0 +1,110 @@
+package templates
+
+import (
+	"bytes"
+	"errors"
+	"path"
+	"strings"
+	"text/template"
+	"time"
+
+	"github.com/mitchellh/go-homedir"
+)
+
+type TemplateData struct {
+	To              string
+	Cc              string
+	Bcc             string
+	From            string
+	Date            string
+	Subject         string
+	// Only available when replying with a quote
+	Original        string
+	OriginalFrom    string
+	OriginalDate    string
+}
+
+func ParseTemplateData(defaults map[string]string) TemplateData {
+	td := TemplateData {
+		To: defaults["To"],
+		Cc: defaults["Cc"],
+		Bcc: defaults["Bcc"],
+		From: defaults["From"],
+		Date: time.Now().Format("Mon Jan 2, 2006"),
+		Subject: defaults["Subject"],
+		Original: defaults["Original"],
+		OriginalFrom: defaults["OriginalFrom"],
+		OriginalDate: defaults["OriginalDate"],
+	}
+	return td
+}
+
+func wrapLine(text string, lineWidth int) string {
+	words := strings.Fields(text)
+	if len(words) == 0 {
+		return ">" + text
+	}
+	wrapped := "> " + words[0]
+	spaceLeft := lineWidth - len(wrapped)
+	for _, word := range words[1:] {
+		if len(word)+1 > spaceLeft {
+			wrapped += "\n> " + word
+			spaceLeft = lineWidth - len(word)
+		} else {
+			wrapped += " " + word
+			spaceLeft -= 1 + len(word)
+		}
+	}
+
+	return wrapped
+}
+
+// Wraping lines at 70 so that with the "> " of the quote it is under 72
+func quote(text string) string {
+	lines := strings.Split(text, "\n")
+	var quoted string
+
+	for _, line := range lines {
+		quoted += wrapLine(line, 70) + "\n"
+	}
+	return quoted
+}
+
+var templateFuncs = template.FuncMap{
+	"quote": quote,
+}
+
+func ParseTemplateFromFile(templateName string, templateDir string, data interface{})  ([]byte, error) {
+	if templateDir == "" {
+		return nil, errors.New("Template Directory is not set.")
+	}
+	templateFile, err := homedir.Expand(path.Join(templateDir, templateName))
+	if err != nil {
+		return nil, err
+	}
+	emailTemplate, err :=
+		template.New(templateName).Funcs(templateFuncs).ParseFiles(templateFile)
+	if err != nil {
+		return nil, err
+	}
+
+	var outString bytes.Buffer
+	if err := emailTemplate.Execute(&outString, data); err != nil {
+		return nil, err
+	}
+	return outString.Bytes(), nil
+}
+
+func ParseTemplate(templateText string, data interface{})  ([]byte, error) {
+	emailTemplate, err :=
+		template.New("email_template").Funcs(templateFuncs).Parse(templateText)
+	if err != nil {
+		return nil, err
+	}
+
+	var outString bytes.Buffer
+	if err := emailTemplate.Execute(&outString, data); err != nil {
+		return nil, err
+	}
+	return outString.Bytes(), nil
+}
diff --git a/templates/quoted_reply b/templates/quoted_reply
new file mode 100644
index 000000000000..3581f47f2327
--- /dev/null
+++ b/templates/quoted_reply
@@ -0,0 +1,2 @@
+on {{.OriginalDate}}, {{.OriginalFrom}} wrote:
+{{quote .Original}}
diff --git a/widgets/aerc.go b/widgets/aerc.go
index af51a0f4d3db..d324908b58a5 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -431,8 +431,11 @@ func (aerc *Aerc) Mailto(addr *url.URL) error {
 			defaults[header] = strings.Join(vals, ",")
 		}
 	}
-	composer := NewComposer(aerc, aerc.Config(),
-		acct.AccountConfig(), acct.Worker(), defaults)
+	composer, err := NewComposer(aerc, aerc.Config(),
+		acct.AccountConfig(), acct.Worker(), "", defaults)
+	if err != nil {
+		return nil
+	}
 	composer.FocusSubject()
 	title := "New email"
 	if subj, ok := defaults["Subject"]; ok {
diff --git a/widgets/compose.go b/widgets/compose.go
index 22c58da52ca1..f7825ce777af 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -23,6 +23,7 @@ import (
 	"github.com/pkg/errors"
 
 	"git.sr.ht/~sircmpwn/aerc/config"
+	"git.sr.ht/~sircmpwn/aerc/lib/templates"
 	"git.sr.ht/~sircmpwn/aerc/lib/ui"
 	"git.sr.ht/~sircmpwn/aerc/worker/types"
 )
@@ -53,7 +54,7 @@ type Composer struct {
 }
 
 func NewComposer(aerc *Aerc, conf *config.AercConfig,
-	acct *config.AccountConfig, worker *types.Worker, defaults map[string]string) *Composer {
+	acct *config.AccountConfig, worker *types.Worker, template string, defaults map[string]string) (*Composer, error) {
 
 	if defaults == nil {
 		defaults = make(map[string]string)
@@ -62,13 +63,14 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
 		defaults["From"] = acct.From
 	}
 
+	templateData := templates.ParseTemplateData(defaults)
 	layout, editors, focusable := buildComposeHeader(
 		conf.Compose.HeaderLayout, defaults)
 
 	email, err := ioutil.TempFile("", "aerc-compose-*.eml")
 	if err != nil {
 		// TODO: handle this better
-		return nil
+		return nil, err
 	}
 
 	c := &Composer{
@@ -86,11 +88,14 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
 	}
 
 	c.AddSignature()
+	if err := c.AddTemplate(template, templateData); err != nil {
+		return nil, err
+	}
 
 	c.updateGrid()
 	c.ShowTerminal()
 
-	return c
+	return c, nil
 }
 
 func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
@@ -163,6 +168,32 @@ func (c *Composer) AppendContents(reader io.Reader) {
 	c.email.Sync()
 }
 
+func (c *Composer) AddTemplate(template string, data interface{}) error {
+	if template == "" {
+		return nil
+	}
+
+	templateText, err := templates.ParseTemplateFromFile(template, c.config.Templates.TemplateDir, data)
+	if err != nil {
+		return err
+	}
+	c.PrependContents(bytes.NewReader(templateText))
+	return nil
+}
+
+func (c *Composer) AddTemplateFromString(template string, data interface{}) error {
+	if template == "" {
+		return nil
+	}
+
+	templateText, err := templates.ParseTemplate(template, data)
+	if err != nil {
+		return err
+	}
+	c.PrependContents(bytes.NewReader(templateText))
+	return nil
+}
+
 func (c *Composer) AddSignature() {
 	var signature []byte
 	if c.acct.SignatureCmd != "" {
-- 
2.23.0
Details
Message ID
<BXW6ORMB8DCA.WADYBHIRUOWA@homura>
In-Reply-To
<20191017182923.1119304-1-sri@vathsan.com> (view parent)
DKIM signature
pass
Download raw message
On Thu Oct 17, 2019 at 8:29 PM Srivathsan Murali wrote:
> But unfortunately, `msg.BodyStructure.Parts` in commands/msg/reply.go is empty.
> Hence, no plain text is found. Any idea why that would be the case?

We probably only fetch the header from IMAP. Would have to fetch the
parts as well. Ideally only when we're sure we need to.