~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
missing
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
missing
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.
Review patch Export thread (mbox)