diff options
Diffstat (limited to 'app/compose.go')
-rw-r--r-- | app/compose.go | 1975 |
1 files changed, 1975 insertions, 0 deletions
diff --git a/app/compose.go b/app/compose.go new file mode 100644 index 00000000..6eda2b0d --- /dev/null +++ b/app/compose.go @@ -0,0 +1,1975 @@ +package app + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/textproto" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/emersion/go-message/mail" + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" + "github.com/pkg/errors" + + "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/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/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +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 + aerc *Aerc + + 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( + aerc *Aerc, 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, + aerc: aerc, + 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 sig, err := c.HasSignature(); !sig && err == nil { + c.AddSignature() + } else if 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 + } + + return c, nil +} + +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) { + c.aerc.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(c.aerc, 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(aerc *Aerc, 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, + ) + } + 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, + ) + } + 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 !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) + } + } else { + attach = !attach + } + } + 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 = c.aerc.Crypto.GetSignerKeyId(s) + if err != nil { + return err + } + } + + r, err := c.aerc.Crypto.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) + } + 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 := c.aerc.Crypto + 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) appendContents(reader io.Reader) error { + _, err := c.email.Seek(0, io.SeekEnd) + 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) HasSignature() (bool, error) { + buf, err := c.GetBody() + if err != nil { + return false, err + } + found := false + scanner := bufio.NewScanner(buf) + for scanner.Scan() { + if scanner.Text() == "-- " { + found = true + break + } + } + return found, scanner.Err() +} + +func (c *Composer) AddSignature() { + var signature []byte + if c.acctConfig.SignatureCmd != "" { + var err error + signature, err = c.readSignatureFromCmd() + if err != nil { + signature = c.readSignatureFromFile() + } + } else { + signature = c.readSignatureFromFile() + } + if len(bytes.TrimSpace(signature)) == 0 { + return + } + signature = ensureSignatureDelimiter(signature) + err := c.appendContents(bytes.NewReader(signature)) + if err != nil { + log.Errorf("appendContents: %s", err) + } +} + +func (c *Composer) readSignatureFromCmd() ([]byte, error) { + sigCmd := c.acctConfig.SignatureCmd + cmd := exec.Command("sh", "-c", sigCmd) + signature, err := cmd.Output() + if err != nil { + return nil, err + } + return signature, nil +} + +func (c *Composer) readSignatureFromFile() []byte { + sigFile := c.acctConfig.SignatureFile + if sigFile == "" { + return nil + } + sigFile = xdg.ExpandHome(sigFile) + signature, err := os.ReadFile(sigFile) + if err != nil { + c.aerc.PushError( + fmt.Sprintf(" Error loading signature from file: %v", sigFile)) + return nil + } + return signature +} + +func ensureSignatureDelimiter(signature []byte) []byte { + buf := bytes.NewBuffer(signature) + scanner := bufio.NewScanner(buf) + for scanner.Scan() { + line := scanner.Text() + if line == "-- " { + // signature contains standard delimiter, we're good + return signature + } + } + // signature does not contain standard delimiter, prepend one + sig := "\n\n-- \n" + strings.TrimLeft(string(signature), " \t\r\n") + return []byte(sig) +} + +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) 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 + } +} + +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 tcell.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 tcell.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") { + hostname, err := getMessageIdHostname(c) + 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 getMessageIdHostname(c *Composer) (string, error) { + if c.acctConfig.SendWithHostname { + return os.Hostname() + } + addrs, err := c.header.AddressList("from") + if err != nil { + return "", err + } + _, domain, found := strings.Cut(addrs[0].Address, "@") + if !found { + return "", fmt.Errorf("Invalid address %q", addrs[0]) + } + return domain, 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 + } + cleartext, err = c.aerc.Crypto.Encrypt(&buf, rcpts, signer, c.aerc.DecryptKeys, header) + if err != nil { + return err + } + } else { + cleartext, err = c.aerc.Crypto.Sign(&buf, signer, c.aerc.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 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) { + 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 tcell.Event) bool { + if event, ok := event.(*tcell.EventMouse); ok { + if event.Buttons() == tcell.Button1 { + 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 { + c.aerc.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 { + c.Close() + c.aerc.RemoveTab(c, true) + c.aerc.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 { + c.aerc.PushError(err.Error()) + err := c.showTerminal() + if err != nil { + c.Close() + c.aerc.RemoveTab(c, true) + c.aerc.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() + } + cmds := []string{ + config.Compose.Editor, + os.Getenv("EDITOR"), + "vi", + "nano", + } + editorName, err := c.aerc.CmdFallbackSearch(cmds) + if err != nil { + c.acct.PushError(fmt.Errorf("could not start editor: %w", err)) + } + editor := exec.Command("/bin/sh", "-c", editorName+" "+c.email.Name()) + 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, + ) + } + c.editors[header] = e + c.layout = append(c.layout, []string{header}) + switch { + case len(c.focusable) == 0: + c.focusable = []ui.MouseableDrawableInteractive{e} + case c.editor != nil: + // Insert focus of new editor before terminal editor + c.focusable = append( + c.focusable[:len(c.focusable)-1], + e, + c.focusable[len(c.focusable)-1], + ) + default: + c.focusable = append( + c.focusable[:len(c.focusable)-1], + e, + ) + } + 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 tcell.Event) { + if event, ok := event.(*tcell.EventMouse); ok { + if event.Buttons() == tcell.Button1 { + 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 tcell.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, + ) + + reviewCommands := [][]string{ + {":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(reviewCommands) + var actions []string + for _, binding := range bindings.Bindings { + inputs := config.FormatKeyStrokes(binding.Input) + outputs := config.FormatKeyStrokes(binding.Output) + found := false + for i, rcmd := range reviewCommands { + if outputs == rcmd[0] { + found = true + if reviewCommands[i][2] == "" { + reviewCommands[i][2] = inputs + } else { + reviewCommands[i][2] += ", " + inputs + } + break + } + } + if !found { + rcmd := []string{outputs, "", inputs} + reviewCommands = append(reviewCommands, rcmd) + } + } + unknownCommands := reviewCommands[knownCommands:] + sort.Slice(unknownCommands, func(i, j int) bool { + return unknownCommands[i][2] < unknownCommands[j][2] + }) + + longest := 0 + for _, rcmd := range reviewCommands { + if len(rcmd[2]) > longest { + longest = len(rcmd[2]) + } + } + + width := longest + if longest < 6 { + width = 6 + } + widthstr := strconv.Itoa(width) + + for _, rcmd := range reviewCommands { + if rcmd[2] != "" { + actions = append(actions, fmt.Sprintf(" %-"+widthstr+"s %-40s %s", + rcmd[2], rcmd[1], rcmd[0])) + } + } + + 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 { + command, found := config.Converters[p.MimeType] + if !found { + // unreachable + return 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 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 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) + c.aerc.statusline.PushError(st) + return false + } + var mk []string + for _, rcpt := range rcpts { + key, err := c.aerc.Crypto.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: + c.aerc.statusline.PushWarning(st) + return false + case config.PgpErrorLevelNone: + return false + case config.PgpErrorLevelError: + // Continue to the default + } + } + c.aerc.statusline.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()) +} |