compose: use a proper header instead of a string map

Prior to this commit, the composer was based on a map[string]string.
While this approach was very versatile, it lead to a constant encoding / decoding
of addresses and other headers.

This commit switches to a different model, where the composer is based on a header.
Commands which want to interact with it can simply set some defaults they would
like to have. Users can overwrite them however they like.

In order to get access to the functions generating / getting the msgid go-message
was upgraded.
This commit is contained in:
Reto Brunner 2020-11-10 20:27:30 +01:00
parent 3ad3a5ede0
commit 20ec2c8eeb
14 changed files with 318 additions and 217 deletions

View file

@ -57,18 +57,17 @@ func (Header) Execute(aerc *widgets.Aerc, args []string) error {
composer, _ := aerc.SelectedTab().(*widgets.Composer)
if !force {
headers, _, err := composer.PrepareHeader()
headers, err := composer.PrepareHeader()
if err != nil {
return err
}
if headers.Has(strings.Title(args[optind])) {
if headers.Has(args[optind]) {
return fmt.Errorf("Header %s already exists", args[optind])
}
}
composer.AddEditor(strings.Title(args[optind]),
strings.Join(args[optind+1:], " "), false)
composer.AddEditor(args[optind], strings.Join(args[optind+1:], " "), false)
return nil
}

View file

@ -40,7 +40,7 @@ func (Postpone) Execute(aerc *widgets.Aerc, args []string) error {
aerc.Logger().Println("Postponing mail")
header, _, err := composer.PrepareHeader()
header, err := composer.PrepareHeader()
if err != nil {
return errors.Wrap(err, "PrepareHeader")
}

View file

@ -4,7 +4,6 @@ import (
"crypto/tls"
"fmt"
"io"
"net/mail"
"net/url"
"os/exec"
"strings"
@ -17,9 +16,11 @@ import (
"github.com/pkg/errors"
"git.sr.ht/~sircmpwn/aerc/lib"
"git.sr.ht/~sircmpwn/aerc/lib/format"
"git.sr.ht/~sircmpwn/aerc/models"
"git.sr.ht/~sircmpwn/aerc/widgets"
"git.sr.ht/~sircmpwn/aerc/worker/types"
"github.com/emersion/go-message/mail"
"golang.org/x/oauth2"
)
@ -71,15 +72,19 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
}
}
header, rcpts, err := composer.PrepareHeader()
header, err := composer.PrepareHeader()
if err != nil {
return errors.Wrap(err, "PrepareHeader")
}
rcpts, err := listRecipients(header)
if err != nil {
return errors.Wrap(err, "listRecipients")
}
if config.From == "" {
return errors.New("No 'From' configured for this account")
}
from, err := mail.ParseAddress(config.From)
from, err := format.ParseAddress(config.From)
if err != nil {
return errors.Wrap(err, "ParseAddress(config.From)")
}
@ -288,7 +293,12 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
composer.Close()
}
})
header, _, _ := composer.PrepareHeader()
header, err := composer.PrepareHeader()
if err != nil {
aerc.PushError(" " + err.Error())
w.Close()
return
}
composer.WriteMessage(header, w)
w.Close()
} else {
@ -299,3 +309,17 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
}()
return nil
}
func listRecipients(h *mail.Header) ([]string, error) {
var rcpts []string
for _, key := range []string{"to", "cc", "bcc"} {
list, err := h.AddressList(key)
if err != nil {
return nil, err
}
for _, addr := range list {
rcpts = append(rcpts, addr.Address)
}
}
return rcpts, nil
}

View file

@ -15,6 +15,7 @@ import (
"git.sr.ht/~sircmpwn/aerc/models"
"git.sr.ht/~sircmpwn/aerc/widgets"
"git.sr.ht/~sircmpwn/aerc/worker/types"
"github.com/emersion/go-message/mail"
"git.sr.ht/~sircmpwn/getopt"
)
@ -49,11 +50,6 @@ func (forward) Execute(aerc *widgets.Aerc, args []string) error {
}
}
to := ""
if len(args) != 1 {
to = strings.Join(args[optind:], ", ")
}
widget := aerc.SelectedTab().(widgets.ProvidesMessage)
acct := widget.SelectedAccount()
if acct == nil {
@ -69,11 +65,19 @@ func (forward) Execute(aerc *widgets.Aerc, args []string) error {
}
acct.Logger().Println("Forwarding email " + msg.Envelope.MessageId)
h := &mail.Header{}
subject := "Fwd: " + msg.Envelope.Subject
defaults := map[string]string{
"To": to,
"Subject": subject,
h.SetSubject(subject)
if len(args) != 1 {
to := strings.Join(args[optind:], ", ")
tolist, err := mail.ParseAddressList(to)
if err != nil {
return fmt.Errorf("invalid to address(es): %v", err)
}
h.SetAddressList("to", tolist)
}
original := models.OriginalMail{
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
@ -81,15 +85,15 @@ func (forward) Execute(aerc *widgets.Aerc, args []string) error {
}
addTab := func() (*widgets.Composer, error) {
composer, err := widgets.NewComposer(aerc, acct, aerc.Config(), acct.AccountConfig(),
acct.Worker(), template, defaults, original)
composer, err := widgets.NewComposer(aerc, acct, aerc.Config(),
acct.AccountConfig(), acct.Worker(), template, h, original)
if err != nil {
aerc.PushError("Error: " + err.Error())
return nil, err
}
tab := aerc.NewTab(composer, subject)
if to == "" {
if !h.Has("to") {
composer.FocusRecipient()
} else {
composer.FocusTerminal()

View file

@ -53,15 +53,9 @@ func (Recall) Execute(aerc *widgets.Aerc, args []string) error {
}
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, err := widgets.NewComposer(aerc, acct, aerc.Config(),
acct.AccountConfig(), acct.Worker(), "", defaults, models.OriginalMail{})
acct.AccountConfig(), acct.Worker(), "", msgInfo.RFC822Headers,
models.OriginalMail{})
if err != nil {
return errors.Wrap(err, "Cannot open a new composer")
}

View file

@ -145,22 +145,22 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
subject = msg.Envelope.Subject
}
defaults := map[string]string{
"To": format.FormatAddresses(to),
"Cc": format.FormatAddresses(cc),
"From": format.AddressForHumans(from),
"Subject": subject,
"In-Reply-To": msg.Envelope.MessageId,
}
h := &mail.Header{}
h.SetAddressList("to", to)
h.SetAddressList("cc", cc)
h.SetAddressList("from", []*mail.Address{from})
h.SetSubject(subject)
h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId})
//TODO: references header
original := models.OriginalMail{
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
RFC822Headers: msg.RFC822Headers,
}
addTab := func() error {
composer, err := widgets.NewComposer(aerc, acct, aerc.Config(),
acct.AccountConfig(), acct.Worker(), template, defaults, original)
acct.AccountConfig(), acct.Worker(), template, h, original)
if err != nil {
aerc.PushError("Error: " + err.Error())
return err

View file

@ -9,6 +9,7 @@ import (
"git.sr.ht/~sircmpwn/aerc/lib"
"git.sr.ht/~sircmpwn/aerc/models"
"git.sr.ht/~sircmpwn/aerc/widgets"
"github.com/emersion/go-message/mail"
)
// Unsubscribe helps people unsubscribe from mailing lists by way of the
@ -84,10 +85,13 @@ func parseUnsubscribeMethods(header string) (methods []*url.URL) {
func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL) error {
widget := aerc.SelectedTab().(widgets.ProvidesMessage)
acct := widget.SelectedAccount()
defaults := map[string]string{
"To": u.Opaque,
"Subject": u.Query().Get("subject"),
h := &mail.Header{}
h.SetSubject(u.Query().Get("subject"))
if to, err := mail.ParseAddressList(u.Opaque); err == nil {
h.SetAddressList("to", to)
}
composer, err := widgets.NewComposer(
aerc,
acct,
@ -95,7 +99,7 @@ func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL) error {
acct.AccountConfig(),
acct.Worker(),
"",
defaults,
h,
models.OriginalMail{},
)
if err != nil {

View file

@ -413,8 +413,10 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
if key == "template-dirs" {
continue
}
// we want to fail during startup if the templates are not ok
// hence we do a dummy execute here
_, err := templates.ParseTemplateFromFile(
val, config.Templates.TemplateDirs, templates.TestTemplateData())
val, config.Templates.TemplateDirs, templates.DummyData())
if err != nil {
return err
}

3
go.mod
View file

@ -11,7 +11,7 @@ require (
github.com/emersion/go-imap-idle v0.0.0-20190519112320-2704abd7050e
github.com/emersion/go-imap-sortthread v1.1.1-0.20201009054724-d020d96306b3
github.com/emersion/go-maildir v0.2.0
github.com/emersion/go-message v0.12.1-0.20200824204225-9094bd0b8bc0
github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd
github.com/emersion/go-pgpmail v0.0.0-20200303213726-db035a3a4139
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.12.1
@ -39,7 +39,6 @@ require (
golang.org/x/net v0.0.0-20200301022130-244492dfa37a // indirect
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect
golang.org/x/text v0.3.3 // indirect
google.golang.org/appengine v1.6.5 // indirect
gopkg.in/ini.v1 v1.44.0 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect

12
go.sum
View file

@ -26,8 +26,8 @@ github.com/emersion/go-maildir v0.2.0 h1:fC4+UVGl8GcQGbFF7AWab2JMf4VbKz+bMNv07xx
github.com/emersion/go-maildir v0.2.0/go.mod h1:I2j27lND/SRLgxROe50Vam81MSaqPFvJ0OHNnDZ7n84=
github.com/emersion/go-message v0.10.4-0.20190609165112-592ace5bc1ca/go.mod h1:3h+HsGTCFHmk4ngJ2IV/YPhdlaOcR6hcgqM3yca9v7c=
github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
github.com/emersion/go-message v0.12.1-0.20200824204225-9094bd0b8bc0 h1:G2VV/Wp2opDvR0ecue3UY/IX1/8OlTmMKKi+ENe1nG0=
github.com/emersion/go-message v0.12.1-0.20200824204225-9094bd0b8bc0/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd h1:6CXxdoOzAyQForkd2U/JNceVyNpmg92alCU2R+4dwIY=
github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd/go.mod h1:SXSs/8KamlsyxjpHL1Q3yf5Jrv7QG5icuvPK1SMcnzw=
github.com/emersion/go-pgpmail v0.0.0-20200303213726-db035a3a4139 h1:JTUbkRuQFtDrl5KHWR2jrh9SUeSDEEEjUcHJkXdAE2Q=
github.com/emersion/go-pgpmail v0.0.0-20200303213726-db035a3a4139/go.mod h1:+Ovy1VQCUKPdjWkOiWvFoiFaWXkqn1PA793VvfEYWQU=
github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
@ -40,6 +40,8 @@ github.com/emersion/go-smtp v0.12.1 h1:1R8BDqrR2HhlGwgFYcOi+BVTvK1bMjAB65QcVpJ5s
github.com/emersion/go-smtp v0.12.1/go.mod h1:SD9V/xa4ndMw77lR3Mf7htkp8RBNYuPh9UeuBs9tpUQ=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
@ -67,6 +69,8 @@ github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i
github.com/martinlindhe/base36 v0.0.0-20190418230009-7c6542dfbb41/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/martinlindhe/base36 v1.0.0 h1:eYsumTah144C0A8P1T/AVSUk5ZoLnhfYFM3OGQxB52A=
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/martinlindhe/base36 v1.1.0 h1:cIwvvwYse/0+1CkUPYH5ZvVIYG3JrILmQEIbLuar02Y=
github.com/martinlindhe/base36 v1.1.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-pointer v0.0.0-20180825124634-49522c3f3791/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
@ -114,8 +118,8 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4-0.20201021145329-22f1617af38e h1:0kyKOEC0chG7FKmnf/1uNwvDLc3NtNTRip2rXAN9nwI=
golang.org/x/text v0.3.4-0.20201021145329-22f1617af38e/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=

View file

@ -61,6 +61,7 @@ func AddressForHumans(a *mail.Address) string {
var atom *regexp.Regexp = regexp.MustCompile("^[a-z0-9!#$%7'*+-/=?^_`{}|~ ]+$")
// FormatAddresses formats a list of addresses into a human readable string
func FormatAddresses(l []*mail.Address) string {
formatted := make([]string, len(l))
for i, a := range l {

View file

@ -4,7 +4,6 @@ import (
"bytes"
"fmt"
"io"
"net/mail"
"os"
"os/exec"
"path"
@ -12,6 +11,8 @@ import (
"text/template"
"time"
"github.com/emersion/go-message/mail"
"git.sr.ht/~sircmpwn/aerc/models"
"github.com/mitchellh/go-homedir"
)
@ -37,47 +38,34 @@ type TemplateData struct {
OriginalMIMEType string
}
func TestTemplateData() TemplateData {
defaults := map[string]string{
"To": "John Doe <john@example.com>",
"Cc": "Josh Doe <josh@example.com>",
"From": "Jane Smith <jane@example.com>",
"Subject": "This is only a test",
func ParseTemplateData(h *mail.Header, original models.OriginalMail) TemplateData {
// we ignore errors as this shouldn't fail the sending / replying even if
// something is wrong with the message we reply to
to, _ := h.AddressList("to")
cc, _ := h.AddressList("cc")
bcc, _ := h.AddressList("bcc")
from, _ := h.AddressList("from")
subject, err := h.Text("subject")
if err != nil {
subject = h.Get("subject")
}
original := models.OriginalMail{
Date: time.Now(),
From: "John Doe <john@example.com>",
Text: "This is only a test text",
MIMEType: "text/plain",
}
return ParseTemplateData(defaults, original)
}
func ParseTemplateData(defaults map[string]string, original models.OriginalMail) TemplateData {
td := TemplateData{
To: parseAddressList(defaults["To"]),
Cc: parseAddressList(defaults["Cc"]),
Bcc: parseAddressList(defaults["Bcc"]),
From: parseAddressList(defaults["From"]),
To: to,
Cc: cc,
Bcc: bcc,
From: from,
Date: time.Now(),
Subject: defaults["Subject"],
Subject: subject,
OriginalText: original.Text,
OriginalFrom: parseAddressList(original.From),
OriginalDate: original.Date,
OriginalMIMEType: original.MIMEType,
}
return td
}
func parseAddressList(list string) []*mail.Address {
addrs, err := mail.ParseAddressList(list)
if err != nil {
return nil
if original.RFC822Headers != nil {
origFrom, _ := original.RFC822Headers.AddressList("from")
td.OriginalFrom = origFrom
}
return addrs
return td
}
// wrap allows to chain wrapText
@ -194,6 +182,34 @@ func findTemplate(templateName string, templateDirs []string) (string, error) {
"Can't find template %q in any of %v ", templateName, templateDirs)
}
//DummyData provides dummy data to test template validity
func DummyData() interface{} {
from := &mail.Address{
Name: "John Doe",
Address: "john@example.com",
}
to := &mail.Address{
Name: "Alice Doe",
Address: "alice@example.com",
}
h := &mail.Header{}
h.SetAddressList("from", []*mail.Address{from})
h.SetAddressList("to", []*mail.Address{to})
oh := &mail.Header{}
oh.SetAddressList("from", []*mail.Address{to})
oh.SetAddressList("to", []*mail.Address{from})
original := models.OriginalMail{
Date: time.Now(),
From: from.String(),
Text: "This is only a test text",
MIMEType: "text/plain",
RFC822Headers: oh,
}
return ParseTemplateData(h, original)
}
func ParseTemplateFromFile(templateName string, templateDirs []string, data interface{}) (io.Reader, error) {
templateFile, err := findTemplate(templateName, templateDirs)
if err != nil {

View file

@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/emersion/go-message/mail"
"github.com/gdamore/tcell"
"github.com/google/shlex"
"golang.org/x/crypto/openpgp"
@ -496,27 +497,38 @@ func (aerc *Aerc) Mailto(addr *url.URL) error {
if acct == nil {
return errors.New("No account selected")
}
defaults := make(map[string]string)
defaults["To"] = addr.Opaque
headerMap := map[string]string{
"cc": "Cc",
"in-reply-to": "In-Reply-To",
"subject": "Subject",
}
var subject string
h := &mail.Header{}
h.SetAddressList("to", []*mail.Address{&mail.Address{Address: addr.Opaque}})
for key, vals := range addr.Query() {
if header, ok := headerMap[strings.ToLower(key)]; ok {
defaults[header] = strings.Join(vals, ",")
switch strings.ToLower(key) {
case "cc":
list, err := mail.ParseAddressList(strings.Join(vals, ","))
if err != nil {
break
}
h.SetAddressList("Cc", list)
case "in-reply-to":
h.SetMsgIDList("In-Reply-To", vals)
case "subject":
subject = strings.Join(vals, ",")
h.SetText("Subject", subject)
default:
// any other header gets ignored on purpose to avoid control headers
// being injected
}
}
composer, err := NewComposer(aerc, acct, aerc.Config(),
acct.AccountConfig(), acct.Worker(), "", defaults, models.OriginalMail{})
acct.AccountConfig(), acct.Worker(), "", h, models.OriginalMail{})
if err != nil {
return nil
}
composer.FocusSubject()
title := "New email"
if subj, ok := defaults["Subject"]; ok {
title = subj
if subject != "" {
title = subject
composer.FocusTerminal()
}
tab := aerc.NewTab(composer, title)

View file

@ -8,7 +8,7 @@ import (
"io/ioutil"
"mime"
"net/http"
gomail "net/mail"
"net/textproto"
"os"
"os/exec"
"path/filepath"
@ -23,6 +23,7 @@ import (
"git.sr.ht/~sircmpwn/aerc/completer"
"git.sr.ht/~sircmpwn/aerc/config"
"git.sr.ht/~sircmpwn/aerc/lib/format"
"git.sr.ht/~sircmpwn/aerc/lib/templates"
"git.sr.ht/~sircmpwn/aerc/lib/ui"
"git.sr.ht/~sircmpwn/aerc/models"
@ -30,7 +31,9 @@ import (
)
type Composer struct {
editors map[string]*headerEditor
editors map[string]*headerEditor // indexes in lower case (from / cc / bcc)
header *mail.Header
parent models.OriginalMail // parent of current message, only set if reply
acctConfig *config.AccountConfig
config *config.AercConfig
@ -38,13 +41,10 @@ type Composer struct {
aerc *Aerc
attachments []string
date time.Time
defaults map[string]string
editor *Terminal
email *os.File
grid *ui.Grid
heditors *ui.Grid // from, to, cc display a user can jump to
msgId string
review *reviewMessage
worker *types.Worker
completer *completer.Completer
@ -61,22 +61,29 @@ type Composer struct {
func NewComposer(aerc *Aerc, acct *AccountView, conf *config.AercConfig,
acctConfig *config.AccountConfig, worker *types.Worker, template string,
defaults map[string]string, original models.OriginalMail) (*Composer, error) {
h *mail.Header, orig models.OriginalMail) (*Composer, error) {
if defaults == nil {
defaults = make(map[string]string)
if h == nil {
h = new(mail.Header)
}
if from := defaults["From"]; from == "" {
defaults["From"] = acctConfig.From
if fl, err := h.AddressList("from"); err != nil || fl == nil {
fl, err = mail.ParseAddressList(acctConfig.From)
// realistically this blows up way before us during the config loading
if err != nil {
return nil, err
}
if fl != nil {
h.SetAddressList("from", fl)
}
}
templateData := templates.ParseTemplateData(defaults, original)
templateData := templates.ParseTemplateData(h, orig)
cmpl := completer.New(conf.Compose.AddressBookCmd, func(err error) {
aerc.PushError(
fmt.Sprintf("could not complete header: %v", err))
worker.Logger.Printf("could not complete header: %v", err)
}, aerc.Logger())
layout, editors, focusable := buildComposeHeader(aerc, cmpl, defaults)
email, err := ioutil.TempFile("", "aerc-compose-*.eml")
if err != nil {
@ -89,18 +96,15 @@ func NewComposer(aerc *Aerc, acct *AccountView, conf *config.AercConfig,
acctConfig: acctConfig,
aerc: aerc,
config: conf,
date: time.Now(),
defaults: defaults,
editors: editors,
header: h,
parent: orig,
email: email,
layout: layout,
msgId: mail.GenerateMessageID(),
worker: worker,
// You have to backtab to get to "From", since you usually don't edit it
focused: 1,
focusable: focusable,
completer: cmpl,
}
c.buildComposeHeader(aerc, cmpl)
if err := c.AddTemplate(template, templateData); err != nil {
return nil, err
@ -113,56 +117,51 @@ func NewComposer(aerc *Aerc, acct *AccountView, conf *config.AercConfig,
return c, nil
}
func buildComposeHeader(aerc *Aerc, cmpl *completer.Completer,
defaults map[string]string) (
newLayout HeaderLayout,
editors map[string]*headerEditor,
focusable []ui.MouseableDrawableInteractive,
) {
layout := aerc.conf.Compose.HeaderLayout
editors = make(map[string]*headerEditor)
focusable = make([]ui.MouseableDrawableInteractive, 0)
func (c *Composer) buildComposeHeader(aerc *Aerc, cmpl *completer.Completer) {
for _, row := range layout {
for _, h := range row {
e := newHeaderEditor(h, "", aerc.SelectedAccount().UiConfig())
c.layout = aerc.conf.Compose.HeaderLayout
c.editors = make(map[string]*headerEditor)
c.focusable = make([]ui.MouseableDrawableInteractive, 0)
for i, row := range c.layout {
for j, h := range row {
h = strings.ToLower(h)
c.layout[i][j] = h // normalize to lowercase
e := newHeaderEditor(h, c.header, aerc.SelectedAccount().UiConfig())
if aerc.conf.Ui.CompletionPopovers {
e.input.TabComplete(cmpl.ForHeader(h), aerc.SelectedAccount().UiConfig().CompletionDelay)
e.input.TabComplete(cmpl.ForHeader(h),
aerc.SelectedAccount().UiConfig().CompletionDelay)
}
editors[h] = e
c.editors[h] = e
switch h {
case "From":
case "from":
// Prepend From to support backtab
focusable = append([]ui.MouseableDrawableInteractive{e}, focusable...)
c.focusable = append([]ui.MouseableDrawableInteractive{e}, c.focusable...)
default:
focusable = append(focusable, e)
c.focusable = append(c.focusable, e)
}
}
}
// Add Cc/Bcc editors to layout if in defaults and not already visible
for _, h := range []string{"Cc", "Bcc"} {
if val, ok := defaults[h]; ok && val != "" {
if _, ok := editors[h]; !ok {
e := newHeaderEditor(h, "", aerc.SelectedAccount().UiConfig())
// Add Cc/Bcc editors to layout if present in header and not already visible
for _, h := range []string{"cc", "bcc"} {
if c.header.Has(h) {
if _, ok := c.editors[h]; !ok {
e := newHeaderEditor(h, c.header, aerc.SelectedAccount().UiConfig())
if aerc.conf.Ui.CompletionPopovers {
e.input.TabComplete(cmpl.ForHeader(h), aerc.SelectedAccount().UiConfig().CompletionDelay)
}
editors[h] = e
focusable = append(focusable, e)
layout = append(layout, []string{h})
c.editors[h] = e
c.focusable = append(c.focusable, e)
c.layout = append(c.layout, []string{h})
}
}
}
// Set default values for all editors
for key := range editors {
if val, ok := defaults[key]; ok {
editors[key].input.Set(val)
delete(defaults, key)
}
// load current header values into all editors
for _, e := range c.editors {
e.loadValue()
}
return layout, editors, focusable
}
func (c *Composer) SetSent() {
@ -205,15 +204,10 @@ func (c *Composer) AddTemplate(template string, data interface{}) error {
return fmt.Errorf("Template loading failed: %v", err)
}
// add the headers contained in the template to the default headers
// copy the headers contained in the template to the compose headers
hf := mr.Header.Fields()
for hf.Next() {
var val string
var err error
if val, err = hf.Text(); err != nil {
val = hf.Value()
}
c.defaults[hf.Key()] = val
c.header.Set(hf.Key(), hf.Value())
}
part, err := mr.NextPart()
@ -293,7 +287,7 @@ func (c *Composer) FocusRecipient() *Composer {
// OnHeaderChange registers an OnChange callback for the specified header.
func (c *Composer) OnHeaderChange(header string, fn func(subject string)) {
if editor, ok := c.editors[header]; ok {
if editor, ok := c.editors[strings.ToLower(header)]; ok {
editor.OnChange(func() {
fn(editor.input.String())
})
@ -378,49 +372,24 @@ func (c *Composer) Worker() *types.Worker {
return c.worker
}
func (c *Composer) PrepareHeader() (*mail.Header, []string, error) {
header := &mail.Header{}
for h, val := range c.defaults {
if val == "" {
continue
}
header.SetText(h, val)
}
header.SetText("Message-Id", c.msgId)
header.SetDate(c.date)
headerKeys := make([]string, 0, len(c.editors))
for key := range c.editors {
headerKeys = append(headerKeys, key)
//PrepareHeader finalizes the header, adding the value from the editors
func (c *Composer) PrepareHeader() (*mail.Header, error) {
for _, editor := range c.editors {
editor.storeValue()
}
var rcpts []string
for h, editor := range c.editors {
val := editor.input.String()
if val == "" {
continue
}
switch h {
case "From", "To", "Cc", "Bcc": // Address headers
hdrRcpts, err := gomail.ParseAddressList(val)
if err != nil {
return nil, nil, errors.Wrapf(err, "ParseAddressList(%s)", val)
}
edRcpts := make([]*mail.Address, len(hdrRcpts))
for i, addr := range hdrRcpts {
edRcpts[i] = (*mail.Address)(addr)
}
header.SetAddressList(h, edRcpts)
if h != "From" {
for _, addr := range edRcpts {
rcpts = append(rcpts, addr.Address)
}
}
default:
header.SetText(h, val)
// control headers not normally set by the user
// repeated calls to PrepareHeader should be a noop
if !c.header.Has("Message-Id") {
err := c.header.GenerateMessageID()
if err != nil {
return nil, err
}
}
return header, rcpts, nil
if !c.header.Has("Date") {
c.header.SetDate(time.Now())
}
return c.header, nil
}
func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error {
@ -639,39 +608,51 @@ func (c *Composer) FocusEditor(editor *headerEditor) {
// AddEditor appends a new header editor to the compose window.
func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
if _, ok := c.editors[header]; ok {
if appendHeader {
header := c.editors[header].input.String()
value = strings.TrimSpace(header) + ", " + value
var editor *headerEditor
header = strings.ToLower(header)
if e, ok := c.editors[header]; ok {
e.storeValue() // flush modifications from the user to the header
editor = e
} else {
e := newHeaderEditor(header, c.header,
c.aerc.SelectedAccount().UiConfig())
if c.config.Ui.CompletionPopovers {
e.input.TabComplete(c.completer.ForHeader(header),
c.config.Ui.CompletionDelay)
}
c.editors[header] = e
c.layout = append(c.layout, []string{header})
// Insert focus of new editor before terminal editor
c.focusable = append(
c.focusable[:len(c.focusable)-1],
e,
c.focusable[len(c.focusable)-1],
)
editor = e
}
if appendHeader {
currVal := editor.input.String()
if currVal != "" {
value = strings.TrimSpace(currVal) + ", " + value
}
}
if value != "" || appendHeader {
c.editors[header].input.Set(value)
if value == "" {
c.FocusEditor(c.editors[header])
}
return
editor.storeValue()
}
e := newHeaderEditor(header, value, c.aerc.SelectedAccount().UiConfig())
if c.config.Ui.CompletionPopovers {
e.input.TabComplete(c.completer.ForHeader(header), c.config.Ui.CompletionDelay)
}
c.editors[header] = e
c.layout = append(c.layout, []string{header})
// Insert focus of new editor before terminal editor
c.focusable = append(
c.focusable[:len(c.focusable)-1],
e,
c.focusable[len(c.focusable)-1],
)
c.updateGrid()
if value == "" {
c.FocusEditor(c.editors[header])
}
c.updateGrid()
}
// updateGrid should be called when the underlying header layout is changed.
func (c *Composer) updateGrid() {
heditors, height := c.layout.grid(
func(h string) ui.Drawable { return c.editors[h] },
func(h string) ui.Drawable {
return c.editors[h]
},
)
if c.grid == nil {
@ -707,21 +688,82 @@ func (c *Composer) reloadEmail() error {
type headerEditor struct {
name string
header *mail.Header
focused bool
input *ui.TextInput
uiConfig config.UIConfig
}
func newHeaderEditor(name string, value string, uiConfig config.UIConfig) *headerEditor {
return &headerEditor{
input: ui.NewTextInput(value, uiConfig),
func newHeaderEditor(name string, h *mail.Header,
uiConfig config.UIConfig) *headerEditor {
he := &headerEditor{
input: ui.NewTextInput("", uiConfig),
name: name,
header: h,
uiConfig: uiConfig,
}
he.loadValue()
return he
}
//extractHumanHeaderValue extracts the human readable string for key from the
//header. If a parsing error occurs the raw value is returned
func extractHumanHeaderValue(key string, h *mail.Header) string {
var val string
var err error
switch strings.ToLower(key) {
case "to", "from", "cc", "bcc":
var list []*mail.Address
list, err = h.AddressList(key)
val = format.FormatAddresses(list)
default:
val, err = h.Text(key)
}
if err != nil {
// if we can't parse it, show it raw
val = h.Get(key)
}
return val
}
//loadValue loads the value of he.name form the underlying header
//the value is decoded and meant for human consumption.
//decoding issues are ignored and return their raw values
func (he *headerEditor) loadValue() {
he.input.Set(extractHumanHeaderValue(he.name, he.header))
he.input.Invalidate()
}
//storeValue writes the current state back to the underlying header.
//errors are ignored
func (he *headerEditor) storeValue() {
val := he.input.String()
switch strings.ToLower(he.name) {
case "to", "from", "cc", "bcc":
list, err := mail.ParseAddressList(val)
if err == nil {
he.header.SetAddressList(he.name, list)
} else {
// garbage, but it'll blow up upon sending and the user can
// fix the issue
he.header.SetText(he.name, val)
}
val = format.FormatAddresses(list)
default:
he.header.SetText(he.name, val)
}
}
//setValue overwrites the current value of the header editor and flushes it
//to the underlying header
func (he *headerEditor) setValue(val string) {
he.input.Set(val)
he.storeValue()
}
func (he *headerEditor) Draw(ctx *ui.Context) {
name := he.name + " "
normalized := textproto.CanonicalMIMEHeaderKey(he.name)
name := normalized + " "
size := runewidth.StringWidth(name)
defaultStyle := he.uiConfig.GetStyle(config.STYLE_DEFAULT)
headerStyle := he.uiConfig.GetStyle(config.STYLE_HEADER)