1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2025-02-22 14:53:57 +01:00
aerc/app/compose.go
Robin Jarry d2ff2c3c10 compose: group aliased bindings on review screen
Aliased bindings were grouped before and there was no good reason not to
do so.

Only group aliased bindings if they don't have any annotation.

Fixes: 928e5ae026 ("compose: only show default annotations if absent from config")
Signed-off-by: Robin Jarry <robin@jarry.cc>
Tested-by: Skejg <grolleman@zoho.com>
2025-01-24 09:28:03 +01:00

1995 lines
46 KiB
Go

package app
import (
"bufio"
"bytes"
"fmt"
"io"
"net/textproto"
"os"
"os/exec"
"path/filepath"
"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) Parent() *models.OriginalMail {
return c.parent
}
func (c *Composer) SwitchAccount(newAcct *AccountView) error {
// 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)
var sep string
if value == "" {
sep = ":"
} else {
sep = ": "
}
_, err = fmt.Fprintf(c.email, "%s%s%s%s", key, sep, value, lineEnding)
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 {
if c.editor == nil {
he.focused = false
} else {
c.focusActiveWidget(false)
c.focused = i
c.focusActiveWidget(true)
}
return
}
}
}
func (c *Composer) Focus(focus bool) {
c.Lock()
if c.editor != nil {
c.focusActiveWidget(focus)
}
c.Unlock()
}
func (c *Composer) Show(visible bool) {
c.Lock()
if w := c.focusedWidget(); w != nil && c.editor != 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)
}
// merge repeated to, cc, and bcc headers into a single one of each
for _, key := range []string{"To", "Cc", "Bcc"} {
fields := msg.Header.FieldsByKey(key)
if fields.Len() <= 1 {
continue
}
var addrs []*mail.Address
for fields.Next() {
if strings.TrimSpace(fields.Value()) == "" {
continue
}
al, err := mail.ParseAddressList(fields.Value())
if err != nil {
return nil, fmt.Errorf(
"%s: cannot parse address list: %w", key, err)
}
addrs = append(addrs, al...)
}
msg.Header.SetAddressList(key, addrs)
PushWarning(fmt.Sprintf(
"Multiple %s headers found; merged in a single one.", key))
}
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()
// RemoveTab() on error must be called *AFTER* c.Unlock() but the defer
// statement does the exact opposite (last defer statement is executed
// first). Use an explicit list that begins with unlocking first.
deferred := []func(){c.Unlock}
defer func() {
for _, d := range deferred {
d()
}
}()
if c.editor == nil {
return
}
if e := c.reopenEmailFile(); e != nil {
PushError("Failed to reopen email file: " + e.Error())
}
editor := c.editor
deferred = append(deferred, 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 {
deferred = append(deferred, func() {
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 {
deferred = append(deferred, func() {
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 || config.Compose.FocusBody {
c.focusTerminalPriv()
}
return nil
}
func (c *Composer) PrevField() bool {
c.Lock()
defer c.Unlock()
if c.editHeaders || c.editor == nil {
return false
}
c.focusActiveWidget(false)
c.focused--
if c.focused == -1 {
c.focused = len(c.focusable) - 1
}
c.focusActiveWidget(true)
return true
}
func (c *Composer) NextField() bool {
c.Lock()
defer c.Unlock()
if c.editHeaders || c.editor == nil {
return false
}
c.focusActiveWidget(false)
c.focused = (c.focused + 1) % len(c.focusable)
c.focusActiveWidget(true)
return true
}
func (c *Composer) FocusEditor(editor string) bool {
c.Lock()
defer c.Unlock()
if c.editHeaders || c.editor == nil {
return false
}
return c.focusEditor(editor)
}
func (c *Composer) focusEditor(editor string) bool {
editor = strings.ToLower(editor)
c.focusActiveWidget(false)
defer c.focusActiveWidget(true)
for i, f := range c.focusable {
e := f.(*headerEditor)
if strings.ToLower(e.name) == editor {
c.focused = i
return true
}
}
return false
}
// 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
}
var defaultAnnotations = map[string]string{
":send<enter>": "Send",
":edit<enter>": "Edit (body and headers)",
":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",
}
func newReviewMessage(composer *Composer, err error) *reviewMessage {
bindings := config.Binds.ComposeReview.ForAccount(
composer.acctConfig.Name,
)
bindings = bindings.ForFolder(composer.SelectedDirectory())
type reviewCmd struct {
input string
output string
annotation string
}
var reviewCmds []reviewCmd
for _, binding := range bindings.Bindings {
if binding.Annotation == "-" {
// explicitly hidden by user
continue
}
inputs := config.FormatKeyStrokes(binding.Input)
outputs := config.FormatKeyStrokes(binding.Output)
annotation := binding.Annotation
if annotation == "" {
for i := range reviewCmds {
r := &reviewCmds[i]
if r.output == outputs {
// aliased action with a different binding
r.input += ", " + inputs
goto next
}
}
annotation = defaultAnnotations[outputs]
}
reviewCmds = append(reviewCmds, reviewCmd{
input: inputs,
output: outputs,
annotation: annotation,
})
next:
}
longest := 0
for _, rcmd := range reviewCmds {
if len(rcmd.input) > longest {
longest = len(rcmd.input)
}
}
const maxInputWidth = 6
width := longest
if longest < maxInputWidth {
width = maxInputWidth
}
widthstr := strconv.Itoa(width)
var actions []string
for _, rcmd := range reviewCmds {
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
uiConf := c.acct.UiConfig()
err := templates.Render(uiConf.TabTitleComposer, &buf,
data.Data())
if err != nil {
c.acct.PushError(err)
return
}
c.Tab.SetTitle(buf.String())
}