package app
import (
"bufio"
"bytes"
"fmt"
"io"
"net/textproto"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/emersion/go-message/mail"
"github.com/mattn/go-runewidth"
"github.com/pkg/errors"
"git.sr.ht/~rjarry/aerc/commands/mode"
"git.sr.ht/~rjarry/aerc/completer"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/send"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/templates"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rockorager/vaxis"
)
type Composer struct {
sync.Mutex
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
acct *AccountView
seldir string
attachments []lib.Attachment
editor *Terminal
email *os.File
grid atomic.Value
heditors atomic.Value // from, to, cc display a user can jump to
review *reviewMessage
worker *types.Worker
completer *completer.Completer
crypto *cryptoStatus
sign bool
encrypt bool
attachKey bool
editHeaders bool
layout HeaderLayout
focusable []ui.MouseableDrawableInteractive
focused int
sent bool
archive string
recalledFrom string
postponed bool
onClose []func(ti *Composer)
width int
textParts []*lib.Part
Tab *ui.Tab
}
func NewComposer(
acct *AccountView, acctConfig *config.AccountConfig,
worker *types.Worker, editHeaders bool, template string,
h *mail.Header, orig *models.OriginalMail, body io.Reader,
) (*Composer, error) {
if h == nil {
h = new(mail.Header)
}
email, err := os.CreateTemp("", "aerc-compose-*.eml")
if err != nil {
// TODO: handle this better
return nil, err
}
c := &Composer{
acct: acct,
acctConfig: acctConfig,
seldir: acct.Directories().Selected(),
header: h,
parent: orig,
email: email,
worker: worker,
// You have to backtab to get to "From", since you usually don't edit it
focused: 1,
completer: nil,
editHeaders: editHeaders,
}
data := state.NewDataSetter()
data.SetAccount(acct.acct)
data.SetFolder(acct.Directories().SelectedDirectory())
data.SetHeaders(h, orig)
data.SetComposer(c)
if err := c.addTemplate(template, data.Data(), body); err != nil {
return nil, err
}
if err := c.setupFor(acct); err != nil {
return nil, err
}
if err := c.ShowTerminal(editHeaders); err != nil {
return nil, err
}
mode.NoQuit()
return c, nil
}
func (c *Composer) SelectedDirectory() string {
return c.seldir
}
func (c *Composer) SwitchAccount(newAcct *AccountView) error {
if c.acct == newAcct {
log.Tracef("same accounts: no switch")
return nil
}
// sync the header with the editors
for _, editor := range c.editors {
editor.storeValue()
}
// ensure that from header is updated, so remove it
c.header.Del("from")
c.header.Del("message-id")
// update entire composer with new the account
if err := c.setupFor(newAcct); err != nil {
return err
}
// sync the header with the editors
for _, editor := range c.editors {
editor.loadValue()
}
c.resetReview()
c.Invalidate()
log.Debugf("account successfully switched")
return nil
}
func (c *Composer) setupFor(view *AccountView) error {
c.Lock()
defer c.Unlock()
// set new account
c.acct = view
c.worker = view.Worker()
c.acctConfig = c.acct.AccountConfig()
// Set from header if not already in header
if fl, err := c.header.AddressList("from"); err != nil || fl == nil {
c.header.SetAddressList("from", []*mail.Address{view.acct.From})
}
if !c.header.Has("to") {
c.header.SetAddressList("to", make([]*mail.Address, 0))
}
if !c.header.Has("subject") {
c.header.SetSubject("")
}
// update completer
cmd := view.acct.AddressBookCmd
if cmd == "" {
cmd = config.Compose.AddressBookCmd
}
cmpl := completer.New(cmd, func(err error) {
PushError(
fmt.Sprintf("could not complete header: %v", err))
log.Errorf("could not complete header: %v", err)
})
c.completer = cmpl
// if editor already exists, we have to get it from the focusable slice
// because this will be rebuild during buildComposeHeader()
var focusEditor ui.MouseableDrawableInteractive
if c.editor != nil && len(c.focusable) > 0 {
focusEditor = c.focusable[len(c.focusable)-1]
}
// rebuild editors and focusable slice
c.buildComposeHeader(cmpl)
// restore the editor in the focusable list
if focusEditor != nil {
c.focusable = append(c.focusable, focusEditor)
}
if c.focused >= len(c.focusable) {
c.focused = len(c.focusable) - 1
}
// update the crypto parts
c.crypto = nil
c.sign = false
if c.acct.acct.PgpAutoSign {
err := c.SetSign(true)
log.Warnf("failed to enable message signing: %v", err)
}
c.encrypt = false
if c.acct.acct.PgpOpportunisticEncrypt {
c.SetEncrypt(true)
}
err := c.updateCrypto()
if err != nil {
log.Warnf("failed to update crypto: %v", err)
}
// redraw the grid
c.updateGrid()
return nil
}
func (c *Composer) buildComposeHeader(cmpl *completer.Completer) {
c.layout = config.Compose.HeaderLayout
c.editors = make(map[string]*headerEditor)
c.focusable = make([]ui.MouseableDrawableInteractive, 0)
uiConfig := c.acct.UiConfig()
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, uiConfig)
if uiConfig.CompletionPopovers {
e.input.TabComplete(
cmpl.ForHeader(h),
uiConfig.CompletionDelay,
uiConfig.CompletionMinChars,
&config.Binds.Compose.CompleteKey,
)
}
c.editors[h] = e
switch h {
case "from":
// Prepend From to support backtab
c.focusable = append([]ui.MouseableDrawableInteractive{e}, c.focusable...)
default:
c.focusable = append(c.focusable, e)
}
e.OnChange(func() {
c.setTitle()
ui.Invalidate()
})
e.OnFocusLost(func() {
c.PrepareHeader() //nolint:errcheck // tab title only, fine if it's not valid yet
c.setTitle()
ui.Invalidate()
})
}
}
// 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, uiConfig)
if uiConfig.CompletionPopovers {
e.input.TabComplete(
cmpl.ForHeader(h),
uiConfig.CompletionDelay,
uiConfig.CompletionMinChars,
&config.Binds.Compose.CompleteKey,
)
}
c.editors[h] = e
c.focusable = append(c.focusable, e)
c.layout = append(c.layout, []string{h})
}
}
}
// load current header values into all editors
for _, e := range c.editors {
e.loadValue()
}
}
func (c *Composer) headerOrder() []string {
var order []string
for _, row := range c.layout {
order = append(order, row...)
}
return order
}
func (c *Composer) SetSent(archive string) {
c.sent = true
c.archive = archive
}
func (c *Composer) Sent() bool {
return c.sent
}
func (c *Composer) SetPostponed() {
c.postponed = true
}
func (c *Composer) Postponed() bool {
return c.postponed
}
func (c *Composer) SetRecalledFrom(folder string) {
c.recalledFrom = folder
}
func (c *Composer) RecalledFrom() string {
return c.recalledFrom
}
func (c *Composer) Archive() string {
return c.archive
}
func (c *Composer) SetAttachKey(attach bool) error {
if c.crypto == nil {
if err := c.updateCrypto(); err != nil {
return err
}
}
if !attach {
name := c.crypto.signKey + ".asc"
found := false
for _, a := range c.attachments {
if a.Name() == name {
found = true
}
}
if found {
err := c.DeleteAttachment(name)
if err != nil {
return fmt.Errorf("failed to delete attachment '%s: %w", name, err)
}
}
}
if attach {
var s string
var err error
if c.crypto.signKey == "" {
if c.acctConfig.PgpKeyId != "" {
s = c.acctConfig.PgpKeyId
} else {
s = c.acctConfig.From.Address
}
c.crypto.signKey, err = CryptoProvider().GetSignerKeyId(s)
if err != nil {
return err
}
}
r, err := CryptoProvider().ExportKey(c.crypto.signKey)
if err != nil {
return err
}
newPart, err := lib.NewPart(
"application/pgp-keys",
map[string]string{"charset": "UTF-8"},
r,
)
if err != nil {
return err
}
c.attachments = append(c.attachments,
lib.NewPartAttachment(
newPart,
c.crypto.signKey+".asc",
),
)
}
c.attachKey = attach
c.resetReview()
return nil
}
func (c *Composer) AttachKey() bool {
return c.attachKey
}
func (c *Composer) SetSign(sign bool) error {
c.sign = sign
err := c.updateCrypto()
if err != nil {
c.sign = !sign
return fmt.Errorf("Cannot sign message: %w", err)
}
if c.acct.acct.PgpAttachKey {
if err := c.SetAttachKey(sign); err != nil {
return err
}
}
return nil
}
func (c *Composer) Sign() bool {
return c.sign
}
func (c *Composer) SetEncrypt(encrypt bool) *Composer {
if !encrypt {
c.encrypt = encrypt
err := c.updateCrypto()
if err != nil {
log.Warnf("failed to update crypto: %v", err)
}
return c
}
// Check on any attempt to encrypt, and any lost focus of "to", "cc", or
// "bcc" field. Use OnFocusLost instead of OnChange to limit keyring checks
c.encrypt = c.checkEncryptionKeys("")
if c.crypto.setEncOneShot {
// Prevent registering a lot of callbacks
c.OnFocusLost("to", c.checkEncryptionKeys)
c.OnFocusLost("cc", c.checkEncryptionKeys)
c.OnFocusLost("bcc", c.checkEncryptionKeys)
c.crypto.setEncOneShot = false
}
return c
}
func (c *Composer) Encrypt() bool {
return c.encrypt
}
func (c *Composer) updateCrypto() error {
if c.crypto == nil {
uiConfig := c.acct.UiConfig()
c.crypto = newCryptoStatus(uiConfig)
}
if c.sign {
cp := CryptoProvider()
s, err := c.Signer()
if err != nil {
return errors.Wrap(err, "Signer")
}
c.crypto.signKey, err = cp.GetSignerKeyId(s)
if err != nil {
return err
}
}
st := ""
switch {
case c.sign && c.encrypt:
st = fmt.Sprintf("Sign (%s) & Encrypt", c.crypto.signKey)
case c.sign:
st = fmt.Sprintf("Sign (%s)", c.crypto.signKey)
case c.encrypt:
st = "Encrypt"
}
c.crypto.status.Text(st)
c.updateGrid()
return nil
}
func (c *Composer) writeEml(reader io.Reader) error {
// .eml files must always use '\r\n' line endings, but some editors
// don't support these, so if they are using one of those, the
// line-endings are transformed
lineEnding := "\r\n"
if config.Compose.LFEditor {
lineEnding = "\n"
}
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
_, err := c.email.WriteString(scanner.Text() + lineEnding)
if err != nil {
return err
}
}
if scanner.Err() != nil {
return scanner.Err()
}
return c.email.Sync()
}
// Note: this does not reload the editor. You must call this before the first
// Draw() call.
func (c *Composer) setContents(reader io.Reader) error {
_, err := c.email.Seek(0, io.SeekStart)
if err != nil {
return err
}
err = c.email.Truncate(0)
if err != nil {
return err
}
lineEnding := "\r\n"
if config.Compose.LFEditor {
lineEnding = "\n"
}
if c.editHeaders {
for _, h := range c.headerOrder() {
var value string
switch h {
case "to", "from", "cc", "bcc":
addresses, err := c.header.AddressList(h)
if err != nil {
log.Warnf("header.AddressList: %s", err)
value, err = c.header.Text(h)
if err != nil {
log.Warnf("header.Text: %s", err)
value = c.header.Get(h)
}
} else {
addr := make([]string, 0, len(addresses))
for _, a := range addresses {
addr = append(addr, format.AddressForHumans(a))
}
value = strings.Join(addr, ","+lineEnding+"\t")
}
default:
value, err = c.header.Text(h)
if err != nil {
log.Warnf("header.Text: %s", err)
value = c.header.Get(h)
}
}
key := textproto.CanonicalMIMEHeaderKey(h)
_, err = fmt.Fprintf(c.email, "%s: %s"+lineEnding, key, value)
if err != nil {
return err
}
}
_, err = c.email.WriteString(lineEnding)
if err != nil {
return err
}
}
return c.writeEml(reader)
}
func (c *Composer) AppendPart(mimetype string, params map[string]string, body io.Reader) error {
if !strings.HasPrefix(mimetype, "text") {
return fmt.Errorf("can only append text mimetypes")
}
for _, part := range c.textParts {
if part.MimeType == mimetype {
return fmt.Errorf("%s part already exists", mimetype)
}
}
newPart, err := lib.NewPart(mimetype, params, body)
if err != nil {
return err
}
c.textParts = append(c.textParts, newPart)
c.resetReview()
return nil
}
func (c *Composer) RemovePart(mimetype string) error {
if mimetype == "text/plain" {
return fmt.Errorf("cannot remove text/plain parts")
}
for i, part := range c.textParts {
if part.MimeType != mimetype {
continue
}
c.textParts = append(c.textParts[:i], c.textParts[i+1:]...)
c.resetReview()
return nil
}
return fmt.Errorf("%s part not found", mimetype)
}
func (c *Composer) addTemplate(
template string, data models.TemplateData, body io.Reader,
) error {
var readers []io.Reader
if template != "" {
templateText, err := templates.ParseTemplateFromFile(
template, config.Templates.TemplateDirs, data)
if err != nil {
return err
}
readers = append(readers, templateText)
}
if body != nil {
if len(readers) == 0 {
readers = append(readers, bytes.NewReader([]byte("\r\n")))
}
readers = append(readers, body)
}
if len(readers) == 0 {
return nil
}
buf, err := io.ReadAll(io.MultiReader(readers...))
if err != nil {
return err
}
mr, err := mail.CreateReader(bytes.NewReader(buf))
if err != nil {
// no headers in the template nor body
return c.setContents(bytes.NewReader(buf))
}
// copy the headers contained in the template to the compose headers
hf := mr.Header.Fields()
for hf.Next() {
c.header.Set(hf.Key(), hf.Value())
}
part, err := mr.NextPart()
if err != nil {
return fmt.Errorf("NextPart: %w", err)
}
return c.setContents(part.Body)
}
func (c *Composer) GetBody() (*bytes.Buffer, error) {
_, err := c.email.Seek(0, io.SeekStart)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(c.email)
if c.editHeaders {
// skip headers
for scanner.Scan() {
if scanner.Text() == "" {
break // stop on first empty line
}
}
}
// .eml files must always use '\r\n' line endings
buf := new(bytes.Buffer)
for scanner.Scan() {
buf.WriteString(scanner.Text() + "\r\n")
}
err = scanner.Err()
if err != nil {
return nil, err
}
return buf, nil
}
func (c *Composer) FocusTerminal() *Composer {
c.Lock()
defer c.Unlock()
return c.focusTerminalPriv()
}
func (c *Composer) focusTerminalPriv() *Composer {
if c.editor == nil {
return c
}
c.focusActiveWidget(false)
c.focused = len(c.focusable) - 1
c.focusActiveWidget(true)
return c
}
// OnHeaderChange registers an OnChange callback for the specified header.
func (c *Composer) OnHeaderChange(header string, fn func(subject string)) {
if editor, ok := c.editors[strings.ToLower(header)]; ok {
editor.OnChange(func() {
fn(editor.input.String())
})
}
}
// OnFocusLost registers an OnFocusLost callback for the specified header.
func (c *Composer) OnFocusLost(header string, fn func(input string) bool) {
if editor, ok := c.editors[strings.ToLower(header)]; ok {
editor.OnFocusLost(func() {
fn(editor.input.String())
})
}
}
func (c *Composer) OnClose(fn func(composer *Composer)) {
c.onClose = append(c.onClose, fn)
}
func (c *Composer) Terminal() *Terminal {
return c.editor
}
func (c *Composer) Draw(ctx *ui.Context) {
c.setTitle()
c.width = ctx.Width()
c.grid.Load().(*ui.Grid).Draw(ctx)
}
func (c *Composer) Invalidate() {
ui.Invalidate()
}
func (c *Composer) Close() {
for _, onClose := range c.onClose {
onClose(c)
}
if c.email != nil {
path := c.email.Name()
c.email.Close()
os.Remove(path)
c.email = nil
}
if c.editor != nil {
c.editor.Destroy()
c.editor = nil
}
mode.NoQuitDone()
}
func (c *Composer) Bindings() string {
c.Lock()
defer c.Unlock()
switch c.editor {
case nil:
return "compose::review"
case c.focusedWidget():
return "compose::editor"
default:
return "compose"
}
}
func (c *Composer) focusedWidget() ui.MouseableDrawableInteractive {
if c.focused < 0 || c.focused >= len(c.focusable) {
return nil
}
return c.focusable[c.focused]
}
func (c *Composer) focusActiveWidget(focus bool) {
if w := c.focusedWidget(); w != nil {
w.Focus(focus)
}
}
func (c *Composer) Event(event vaxis.Event) bool {
c.Lock()
defer c.Unlock()
if w := c.focusedWidget(); c.editor != nil && w != nil {
return w.Event(event)
}
return false
}
func (c *Composer) MouseEvent(localX int, localY int, event vaxis.Event) {
c.Lock()
for _, e := range c.focusable {
he, ok := e.(*headerEditor)
if ok && he.focused {
he.focused = false
}
}
c.Unlock()
c.grid.Load().(*ui.Grid).MouseEvent(localX, localY, event)
c.Lock()
defer c.Unlock()
for i, e := range c.focusable {
he, ok := e.(*headerEditor)
if ok && he.focused {
c.focusActiveWidget(false)
c.focused = i
c.focusActiveWidget(true)
return
}
}
}
func (c *Composer) Focus(focus bool) {
c.Lock()
c.focusActiveWidget(focus)
c.Unlock()
}
func (c *Composer) Show(visible bool) {
c.Lock()
if w := c.focusedWidget(); w != nil {
if vis, ok := w.(ui.Visible); ok {
vis.Show(visible)
}
}
c.Unlock()
}
func (c *Composer) Config() *config.AccountConfig {
return c.acctConfig
}
func (c *Composer) Account() *AccountView {
return c.acct
}
func (c *Composer) Worker() *types.Worker {
return c.worker
}
// PrepareHeader finalizes the header, adding the value from the editors
func (c *Composer) PrepareHeader() (*mail.Header, error) {
for _, editor := range c.editors {
editor.storeValue()
}
// control headers not normally set by the user
// repeated calls to PrepareHeader should be a noop
if !c.header.Has("Message-Id") {
froms, err := c.header.AddressList("from")
if err != nil {
return nil, err
}
if len(froms) == 0 {
return nil, fmt.Errorf("no valid From address found")
}
hostname, err := send.GetMessageIdHostname(
c.acctConfig.SendWithHostname, froms[0])
if err != nil {
return nil, err
}
if err := c.header.GenerateMessageIDWithHostname(hostname); err != nil {
return nil, err
}
}
// update the "Date" header every time PrepareHeader is called
if c.acctConfig.SendAsUTC {
c.header.SetDate(time.Now().UTC())
} else {
c.header.SetDate(time.Now())
}
return c.header, nil
}
func (c *Composer) parseEmbeddedHeader() (*mail.Header, error) {
_, err := c.email.Seek(0, io.SeekStart)
if err != nil {
return nil, errors.Wrap(err, "Seek")
}
buf := bytes.NewBuffer([]byte{})
_, err = io.Copy(buf, c.email)
if err != nil {
return nil, fmt.Errorf("mail.ReadMessageCopy: %w", err)
}
if config.Compose.LFEditor {
bytes.ReplaceAll(buf.Bytes(), []byte{'\n'}, []byte{'\r', '\n'})
}
msg, err := mail.CreateReader(buf)
if errors.Is(err, io.EOF) { // completely empty
h := mail.HeaderFromMap(make(map[string][]string))
return &h, nil
} else if err != nil {
return nil, fmt.Errorf("mail.ReadMessage: %w", err)
}
return &msg.Header, nil
}
func getRecipientsEmail(c *Composer) ([]string, error) {
h, err := c.PrepareHeader()
if err != nil {
return nil, errors.Wrap(err, "PrepareHeader")
}
// collect all 'recipients' from header (to:, cc:, bcc:)
rcpts := make(map[string]bool)
for _, key := range []string{"to", "cc", "bcc"} {
list, err := h.AddressList(key)
if err != nil {
continue
}
for _, entry := range list {
if entry != nil {
rcpts[entry.Address] = true
}
}
}
// return email addresses as string slice
results := []string{}
for email := range rcpts {
results = append(results, email)
}
return results, nil
}
func (c *Composer) Signer() (string, error) {
signer := ""
if c.acctConfig.PgpKeyId != "" {
// get key from explicitly set keyid
signer = c.acctConfig.PgpKeyId
} else {
// get signer from `from` header
from, err := c.header.AddressList("from")
if err != nil {
return "", err
}
if len(from) > 0 {
signer = from[0].Address
} else {
// fall back to address from config
signer = c.acctConfig.From.Address
}
}
return signer, nil
}
func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error {
if c.sign || c.encrypt {
var signedHeader mail.Header
signedHeader.SetContentType("text/plain", nil)
var buf bytes.Buffer
var cleartext io.WriteCloser
var err error
signer := ""
if c.sign {
signer, err = c.Signer()
if err != nil {
return errors.Wrap(err, "Signer")
}
}
if c.encrypt {
rcpts, err := getRecipientsEmail(c)
if err != nil {
return err
}
if c.acct.acct.PgpSelfEncrypt {
signer, err := c.Signer()
if err != nil {
return err
}
rcpts = append(rcpts, signer)
}
cleartext, err = CryptoProvider().Encrypt(&buf, rcpts, signer, DecryptKeys, header)
if err != nil {
return err
}
} else {
cleartext, err = CryptoProvider().Sign(&buf, signer, DecryptKeys, header)
if err != nil {
return err
}
}
err = writeMsgImpl(c, &signedHeader, cleartext)
if err != nil {
return err
}
err = cleartext.Close()
if err != nil {
return err
}
_, err = io.Copy(writer, &buf)
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
return nil
} else {
return writeMsgImpl(c, header, writer)
}
}
func (c *Composer) ShouldWarnAttachment() bool {
regex := config.Compose.NoAttachmentWarning
if regex == nil || len(c.attachments) > 0 {
return false
}
body, err := c.GetBody()
if err != nil {
log.Warnf("failed to check for a forgotten attachment: %v", err)
return true
}
return regex.Match(body.Bytes())
}
func (c *Composer) ShouldWarnSubject() bool {
if !config.Compose.EmptySubjectWarning {
return false
}
// ignore errors because the raw header field is sufficient here
subject, _ := c.header.Subject()
return len(subject) == 0
}
func (c *Composer) CheckForMultipartErrors() error {
problems := []string{}
for _, p := range c.textParts {
if p.ConversionError != nil {
text := fmt.Sprintf("%s: %s", p.MimeType, p.ConversionError.Error())
problems = append(problems, text)
}
}
if len(problems) == 0 {
return nil
}
return fmt.Errorf("multipart conversion error: %s", strings.Join(problems, "; "))
}
func writeMsgImpl(c *Composer, header *mail.Header, writer io.Writer) error {
mimeParams := map[string]string{"Charset": "UTF-8"}
if config.Compose.FormatFlowed {
mimeParams["Format"] = "Flowed"
}
body, err := c.GetBody()
if err != nil {
return err
}
if len(c.attachments) == 0 && len(c.textParts) == 0 {
// no attachments
return writeInlineBody(header, body, writer, mimeParams)
} else {
// with attachments
w, err := mail.CreateWriter(writer, *header)
if err != nil {
return errors.Wrap(err, "CreateWriter")
}
newPart, err := lib.NewPart("text/plain", mimeParams, body)
if err != nil {
return err
}
parts := []*lib.Part{newPart}
if err := writeMultipartBody(append(parts, c.textParts...), w); err != nil {
return errors.Wrap(err, "writeMultipartBody")
}
for _, a := range c.attachments {
if err := a.WriteTo(w); err != nil {
return errors.Wrap(err, "writeAttachment")
}
}
w.Close()
}
return nil
}
func writeInlineBody(
header *mail.Header,
body io.Reader,
writer io.Writer,
mimeParams map[string]string,
) error {
header.SetContentType("text/plain", mimeParams)
w, err := mail.CreateSingleInlineWriter(writer, *header)
if err != nil {
return errors.Wrap(err, "CreateSingleInlineWriter")
}
defer w.Close()
if _, err := io.Copy(w, body); err != nil {
return errors.Wrap(err, "io.Copy")
}
return nil
}
// write the message body to the multipart message
func writeMultipartBody(parts []*lib.Part, w *mail.Writer) error {
bi, err := w.CreateInline()
if err != nil {
return errors.Wrap(err, "CreateInline")
}
defer bi.Close()
for _, part := range parts {
bh := mail.InlineHeader{}
bh.SetContentType(part.MimeType, part.Params)
bw, err := bi.CreatePart(bh)
if err != nil {
return errors.Wrap(err, "CreatePart")
}
defer bw.Close()
if _, err := io.Copy(bw, part.NewReader()); err != nil {
return errors.Wrap(err, "io.Copy")
}
}
return nil
}
func (c *Composer) GetAttachments() []string {
var names []string
for _, a := range c.attachments {
names = append(names, a.Name())
}
return names
}
func (c *Composer) AddAttachment(path string) {
path, _ = filepath.Abs(path)
path = xdg.TildeHome(path)
c.attachments = append(c.attachments, lib.NewFileAttachment(path))
c.resetReview()
}
func (c *Composer) AddPartAttachment(name string, mimetype string,
params map[string]string, body io.Reader,
) error {
p, err := lib.NewPart(mimetype, params, body)
if err != nil {
return err
}
c.attachments = append(c.attachments, lib.NewPartAttachment(
p, name,
))
c.resetReview()
return nil
}
func (c *Composer) DeleteAttachment(name string) error {
for i, a := range c.attachments {
if a.Name() == name {
c.attachments = append(c.attachments[:i], c.attachments[i+1:]...)
c.resetReview()
return nil
}
}
return errors.New("attachment does not exist")
}
func (c *Composer) resetReview() {
if c.review != nil {
c.grid.Load().(*ui.Grid).RemoveChild(c.review)
c.review = newReviewMessage(c, nil)
c.grid.Load().(*ui.Grid).AddChild(c.review).At(3, 0)
}
}
func (c *Composer) termEvent(event vaxis.Event) bool {
if event, ok := event.(vaxis.Mouse); ok {
if event.Button == vaxis.MouseLeftButton {
c.FocusTerminal()
return true
}
}
return false
}
func (c *Composer) reopenEmailFile() error {
name := c.email.Name()
f, err := os.OpenFile(name, os.O_RDWR, 0o600)
if err != nil {
return err
}
err = c.email.Close()
c.email = f
return err
}
func (c *Composer) termClosed(err error) {
c.Lock()
defer c.Unlock()
if c.editor == nil {
return
}
if e := c.reopenEmailFile(); e != nil {
PushError("Failed to reopen email file: " + e.Error())
}
editor := c.editor
defer editor.Destroy()
c.editor = nil
c.focusable = c.focusable[:len(c.focusable)-1]
if c.focused >= len(c.focusable) {
c.focused = len(c.focusable) - 1
}
if editor.cmd.ProcessState.ExitCode() > 0 {
RemoveTab(c, true)
PushError("Editor exited with error. Compose aborted!")
return
}
if c.editHeaders {
// parse embedded header when editor is closed
embedHeader, err := c.parseEmbeddedHeader()
if err != nil {
PushError(err.Error())
err := c.showTerminal()
if err != nil {
RemoveTab(c, true)
PushError(err.Error())
}
return
}
// delete previous headers first
for _, h := range c.headerOrder() {
c.delEditor(h)
}
hf := embedHeader.Fields()
for hf.Next() {
if hf.Value() != "" {
// add new header values in order
c.addEditor(hf.Key(), hf.Value(), false)
}
}
}
// prepare review window
c.review = newReviewMessage(c, err)
c.updateGrid()
}
func (c *Composer) ShowTerminal(editHeaders bool) error {
c.Lock()
defer c.Unlock()
if c.editor != nil {
return nil
}
body, err := c.GetBody()
if err != nil {
return err
}
c.editHeaders = editHeaders
err = c.setContents(body)
if err != nil {
return err
}
return c.showTerminal()
}
func (c *Composer) showTerminal() error {
if c.editor != nil {
c.editor.Destroy()
}
editorName, err := CmdFallbackSearch(config.EditorCmds(), false)
if err != nil {
c.acct.PushError(fmt.Errorf("could not start editor: %w", err))
}
editor := exec.Command("/bin/sh", "-c", editorName+" "+c.email.Name())
env := os.Environ()
env = append(env, fmt.Sprintf("AERC_ACCOUNT=%s", c.Account().Name()))
env = append(env, fmt.Sprintf("AERC_ADDRESS_BOOK_CMD=%s", c.Account().AccountConfig().AddressBookCmd))
editor.Env = env
c.editor, err = NewTerminal(editor)
if err != nil {
return err
}
c.editor.OnEvent = c.termEvent
c.editor.OnClose = c.termClosed
c.focusable = append(c.focusable, c.editor)
c.review = nil
c.updateGrid()
if c.editHeaders {
c.focusTerminalPriv()
}
return nil
}
func (c *Composer) PrevField() {
c.Lock()
defer c.Unlock()
if c.editHeaders && c.editor != nil {
return
}
c.focusActiveWidget(false)
c.focused--
if c.focused == -1 {
c.focused = len(c.focusable) - 1
}
c.focusActiveWidget(true)
}
func (c *Composer) NextField() {
c.Lock()
defer c.Unlock()
if c.editHeaders && c.editor != nil {
return
}
c.focusActiveWidget(false)
c.focused = (c.focused + 1) % len(c.focusable)
c.focusActiveWidget(true)
}
func (c *Composer) FocusEditor(editor string) {
c.Lock()
defer c.Unlock()
if c.editHeaders && c.editor != nil {
return
}
c.focusEditor(editor)
}
func (c *Composer) focusEditor(editor string) {
editor = strings.ToLower(editor)
c.focusActiveWidget(false)
for i, f := range c.focusable {
e := f.(*headerEditor)
if strings.ToLower(e.name) == editor {
c.focused = i
break
}
}
c.focusActiveWidget(true)
}
// AddEditor appends a new header editor to the compose window.
func (c *Composer) AddEditor(header string, value string, appendHeader bool) error {
c.Lock()
defer c.Unlock()
if c.editHeaders && c.editor != nil {
return errors.New("header should be added directly in the text editor")
}
value = c.addEditor(header, value, appendHeader)
if value == "" {
c.focusEditor(header)
}
c.updateGrid()
return nil
}
func (c *Composer) addEditor(header string, value string, appendHeader bool) string {
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 {
uiConfig := c.acct.UiConfig()
e := newHeaderEditor(header, c.header, uiConfig)
if uiConfig.CompletionPopovers {
e.input.TabComplete(
c.completer.ForHeader(header),
uiConfig.CompletionDelay,
uiConfig.CompletionMinChars,
&config.Binds.Compose.CompleteKey,
)
}
c.editors[header] = e
c.layout = append(c.layout, []string{header})
if len(c.focusable) == 0 || c.editor == nil {
// no terminal editor, insert at the end
c.focusable = append(c.focusable, e)
} else {
// 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)
editor.storeValue()
}
return value
}
// DelEditor removes a header editor from the compose window.
func (c *Composer) DelEditor(header string) error {
c.Lock()
defer c.Unlock()
if c.editHeaders && c.editor != nil {
return errors.New("header should be removed directly in the text editor")
}
c.delEditor(header)
c.updateGrid()
return nil
}
func (c *Composer) delEditor(header string) {
header = strings.ToLower(header)
c.header.Del(header)
editor, ok := c.editors[header]
if !ok {
return
}
var layout HeaderLayout = make([][]string, 0, len(c.layout))
for _, row := range c.layout {
r := make([]string, 0, len(row))
for _, h := range row {
if h != header {
r = append(r, h)
}
}
if len(r) > 0 {
layout = append(layout, r)
}
}
c.layout = layout
focusable := make([]ui.MouseableDrawableInteractive, 0, len(c.focusable)-1)
for i, f := range c.focusable {
if f == editor {
if c.focused > 0 && c.focused >= i {
c.focused--
}
} else {
focusable = append(focusable, f)
}
}
c.focusable = focusable
c.focusActiveWidget(true)
delete(c.editors, header)
}
// updateGrid should be called when the underlying header layout is changed.
func (c *Composer) updateGrid() {
grid := ui.NewGrid().Columns([]ui.GridSpec{
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
})
if c.editHeaders && c.review == nil {
grid.Rows([]ui.GridSpec{
// 0: editor
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
})
if c.editor != nil {
grid.AddChild(c.editor).At(0, 0)
}
c.grid.Store(grid)
return
}
heditors, height := c.layout.grid(
func(h string) ui.Drawable {
return c.editors[h]
},
)
crHeight := 0
if c.sign || c.encrypt {
crHeight = 1
}
grid.Rows([]ui.GridSpec{
// 0: headers
{Strategy: ui.SIZE_EXACT, Size: ui.Const(height)},
// 1: crypto status
{Strategy: ui.SIZE_EXACT, Size: ui.Const(crHeight)},
// 2: filler line
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
// 3: editor or review
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
})
borderStyle := c.acct.UiConfig().GetStyle(config.STYLE_BORDER)
borderChar := c.acct.UiConfig().BorderCharHorizontal
grid.AddChild(heditors).At(0, 0)
grid.AddChild(c.crypto).At(1, 0)
grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 0)
if c.review != nil {
grid.AddChild(c.review).At(3, 0)
} else if c.editor != nil {
grid.AddChild(c.editor).At(3, 0)
}
c.heditors.Store(heditors)
c.grid.Store(grid)
}
type headerEditor struct {
name string
header *mail.Header
focused bool
input *ui.TextInput
uiConfig *config.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))
ui.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":
if strings.TrimSpace(val) == "" {
// if header is empty, delete it
he.header.Del(he.name)
return
}
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)
}
default:
he.header.SetText(he.name, val)
}
if strings.ToLower(he.name) == "from" {
he.header.Del("message-id")
}
}
func (he *headerEditor) Draw(ctx *ui.Context) {
name := textproto.CanonicalMIMEHeaderKey(he.name)
// Extra character to put a blank cell between the header and the input
size := runewidth.StringWidth(name+":") + 1
defaultStyle := he.uiConfig.GetStyle(config.STYLE_DEFAULT)
headerStyle := he.uiConfig.GetStyle(config.STYLE_HEADER)
ctx.Fill(0, 0, size, ctx.Height(), ' ', defaultStyle)
ctx.Printf(0, 0, headerStyle, "%s:", name)
he.input.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1))
}
func (he *headerEditor) MouseEvent(localX int, localY int, event vaxis.Event) {
if event, ok := event.(vaxis.Mouse); ok {
if event.Button == vaxis.MouseLeftButton {
he.focused = true
}
width := runewidth.StringWidth(he.name + " ")
if localX >= width {
he.input.MouseEvent(localX-width, localY, event)
}
}
}
func (he *headerEditor) Invalidate() {
ui.Invalidate()
}
func (he *headerEditor) Focus(focused bool) {
he.focused = focused
he.input.Focus(focused)
}
func (he *headerEditor) Event(event vaxis.Event) bool {
return he.input.Event(event)
}
func (he *headerEditor) OnChange(fn func()) {
he.input.OnChange(func(_ *ui.TextInput) {
fn()
})
}
func (he *headerEditor) OnFocusLost(fn func()) {
he.input.OnFocusLost(func(_ *ui.TextInput) {
fn()
})
}
type reviewMessage struct {
composer *Composer
grid *ui.Grid
}
func newReviewMessage(composer *Composer, err error) *reviewMessage {
bindings := config.Binds.ComposeReview.ForAccount(
composer.acctConfig.Name,
)
bindings = bindings.ForFolder(composer.SelectedDirectory())
const maxInputWidth = 6
type reviewCmd struct {
output string
annotation string
input string
}
reviewCmds := []reviewCmd{
{":send<enter>", "Send", ""},
{":edit<enter>", "Edit", ""},
{":attach<space>", "Add attachment", ""},
{":detach<space>", "Remove attachment", ""},
{":postpone<enter>", "Postpone", ""},
{":preview<enter>", "Preview message", ""},
{":abort<enter>", "Abort (discard message, no confirmation)", ""},
{":choose -o d discard abort -o p postpone postpone<enter>", "Abort or postpone", ""},
}
knownCommands := len(reviewCmds)
var actions []string
for _, binding := range bindings.Bindings {
inputs := config.FormatKeyStrokes(binding.Input)
outputs := config.FormatKeyStrokes(binding.Output)
found := false
for i, rcmd := range reviewCmds {
if outputs == rcmd.output {
found = true
if reviewCmds[i].input == "" {
reviewCmds[i].input = inputs
} else {
reviewCmds[i].input += ", " + inputs
}
if binding.Annotation != "" {
// overwrite default description with
// user annotations if present
reviewCmds[i].annotation = binding.Annotation
}
break
}
}
if !found {
rcmd := reviewCmd{
output: outputs,
annotation: binding.Annotation,
input: inputs,
}
reviewCmds = append(reviewCmds, rcmd)
}
}
unknownCommands := reviewCmds[knownCommands:]
sort.Slice(unknownCommands, func(i, j int) bool {
return unknownCommands[i].input < unknownCommands[j].input
})
longest := 0
for _, rcmd := range reviewCmds {
if len(rcmd.input) > longest {
longest = len(rcmd.input)
}
}
width := longest
if longest < maxInputWidth {
width = maxInputWidth
}
widthstr := strconv.Itoa(width)
for _, rcmd := range reviewCmds {
if rcmd.input != "" {
actions = append(actions, fmt.Sprintf(" %-"+widthstr+"s %-40s %s",
rcmd.input, rcmd.annotation, rcmd.output))
}
}
spec := []ui.GridSpec{
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
}
for i := 0; i < len(actions)-1; i++ {
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
}
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(2)})
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
for i := 0; i < len(composer.attachments)-1; i++ {
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
}
if len(composer.textParts) > 0 {
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
for i := 0; i < len(composer.textParts); i++ {
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
}
}
// make the last element fill remaining space
spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)})
grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
})
uiConfig := composer.acct.UiConfig()
if err != nil {
grid.AddChild(ui.NewText(err.Error(), uiConfig.GetStyle(config.STYLE_ERROR)))
grid.AddChild(ui.NewText("Press [q] to close this tab.",
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(1, 0)
} else {
grid.AddChild(ui.NewText("Send this email?",
uiConfig.GetStyle(config.STYLE_TITLE))).At(0, 0)
i := 1
for _, action := range actions {
grid.AddChild(ui.NewText(action,
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
i += 1
}
grid.AddChild(ui.NewText("Attachments:",
uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0)
i += 1
if len(composer.attachments) == 0 {
grid.AddChild(ui.NewText("(none)",
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
i += 1
} else {
for _, a := range composer.attachments {
grid.AddChild(ui.NewText(a.Name(), uiConfig.GetStyle(config.STYLE_DEFAULT))).
At(i, 0)
i += 1
}
}
if len(composer.textParts) > 0 {
grid.AddChild(ui.NewText("Parts:",
uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0)
i += 1
grid.AddChild(ui.NewText("text/plain", uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
i += 1
for _, p := range composer.textParts {
err := composer.updateMultipart(p)
if err != nil {
msg := fmt.Sprintf("%s error: %s", p.MimeType, err)
grid.AddChild(ui.NewText(msg,
uiConfig.GetStyle(config.STYLE_ERROR))).At(i, 0)
} else {
grid.AddChild(ui.NewText(p.MimeType,
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
}
i += 1
}
}
}
return &reviewMessage{
composer: composer,
grid: grid,
}
}
func (c *Composer) updateMultipart(p *lib.Part) error {
// conversion errors handling
p.ConversionError = nil
setError := func(e error) error {
p.ConversionError = e
return e
}
if !p.Converted {
// text/* multipart created without a command (e.g. by :accept)
return nil
}
command, found := config.Converters[p.MimeType]
if !found {
// unreachable
return setError(fmt.Errorf("no command defined for mime/type"))
}
// reset part body to avoid it leaving outdated if the command fails
p.Data = nil
body, err := c.GetBody()
if err != nil {
return setError(errors.Wrap(err, "GetBody"))
}
cmd := exec.Command("sh", "-c", command)
cmd.Stdin = body
out, err := cmd.Output()
if err != nil {
var stderr string
var ee *exec.ExitError
if errors.As(err, &ee) {
// append the first 30 chars of stderr if any
stderr = strings.Trim(string(ee.Stderr), " \t\n\r")
stderr = strings.ReplaceAll(stderr, "\n", "; ")
if stderr != "" {
stderr = fmt.Sprintf(": %.30s", stderr)
}
}
return setError(fmt.Errorf("%s: %w%s", command, err, stderr))
}
p.Data = out
return nil
}
func (rm *reviewMessage) Invalidate() {
ui.Invalidate()
}
func (rm *reviewMessage) Draw(ctx *ui.Context) {
rm.grid.Draw(ctx)
}
type cryptoStatus struct {
title string
status *ui.Text
uiConfig *config.UIConfig
signKey string
setEncOneShot bool
}
func newCryptoStatus(uiConfig *config.UIConfig) *cryptoStatus {
defaultStyle := uiConfig.GetStyle(config.STYLE_DEFAULT)
return &cryptoStatus{
title: "Security",
status: ui.NewText("", defaultStyle),
uiConfig: uiConfig,
signKey: "",
setEncOneShot: true,
}
}
func (cs *cryptoStatus) Draw(ctx *ui.Context) {
// Extra character to put a blank cell between the header and the input
size := runewidth.StringWidth(cs.title+":") + 1
defaultStyle := cs.uiConfig.GetStyle(config.STYLE_DEFAULT)
titleStyle := cs.uiConfig.GetStyle(config.STYLE_HEADER)
ctx.Fill(0, 0, size, ctx.Height(), ' ', defaultStyle)
ctx.Printf(0, 0, titleStyle, "%s:", cs.title)
cs.status.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1))
}
func (cs *cryptoStatus) Invalidate() {
ui.Invalidate()
}
func (c *Composer) checkEncryptionKeys(_ string) bool {
rcpts, err := getRecipientsEmail(c)
if err != nil {
// checkEncryptionKeys gets registered as a callback and must
// explicitly call c.SetEncrypt(false) when encryption is not possible
c.SetEncrypt(false)
st := fmt.Sprintf("Cannot encrypt: %v", err)
aerc.statusline.PushError(st)
return false
}
var mk []string
for _, rcpt := range rcpts {
key, err := CryptoProvider().GetKeyId(rcpt)
if err != nil || key == "" {
mk = append(mk, rcpt)
}
}
encrypt := true
switch {
case len(mk) > 0:
c.SetEncrypt(false)
st := fmt.Sprintf("Cannot encrypt, missing keys: %s", strings.Join(mk, ", "))
if c.Config().PgpOpportunisticEncrypt {
switch c.Config().PgpErrorLevel {
case config.PgpErrorLevelWarn:
aerc.statusline.PushWarning(st)
return false
case config.PgpErrorLevelNone:
return false
case config.PgpErrorLevelError:
// Continue to the default
}
}
PushError(st)
encrypt = false
case len(rcpts) == 0:
encrypt = false
}
// If callbacks were registered, encrypt will be set when user removes
// recipients with missing keys
c.encrypt = encrypt
err = c.updateCrypto()
if err != nil {
log.Warnf("failed update crypto: %v", err)
}
return true
}
// setTitle executes the title template and sets the tab title
func (c *Composer) setTitle() {
if c.Tab == nil {
return
}
header := c.header.Copy()
// Get subject direct from the textinput
subject, ok := c.editors["subject"]
if ok {
header.SetSubject(subject.input.String())
}
if header.Get("subject") == "" {
header.SetSubject("New Email")
}
data := state.NewDataSetter()
data.SetAccount(c.acctConfig)
data.SetFolder(c.acct.Directories().SelectedDirectory())
data.SetHeaders(&header, c.parent)
var buf bytes.Buffer
err := templates.Render(c.acct.UiConfig().TabTitleComposer, &buf,
data.Data())
if err != nil {
c.acct.PushError(err)
return
}
c.Tab.SetTitle(buf.String())
}