mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-03-02 18:23:33 +01:00
On wide screens, it is common to have aerc running in a wide window. In that case, the message viewer and the composer aligned to the left leave the user looking at the side of the window for long periods of time, which can be tiresome if the window sits on a side of the screen. Introduce an option to allow the message viewer and the composer to be centered on the window by setting a fixed width for them and padding both sides with empty space. Changelog-added: The editor and the message viewer can now be centered on screen using a fixed width. Signed-off-by: inwit <inwit@sindominio.net> Tested-by: Antonin Godard <antonin@godard.cc> Acked-by: Robin Jarry <robin@jarry.cc>
2034 lines
47 KiB
Go
2034 lines
47 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
|
|
centeredLayoutWidth int
|
|
|
|
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)
|
|
}
|
|
|
|
composePath := xdg.StatePath("aerc", "compose")
|
|
_ = os.MkdirAll(composePath, 0o700)
|
|
|
|
email, err := os.CreateTemp(composePath, "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,
|
|
centeredLayoutWidth: acct.UiConfig().CenteredLayoutWidth,
|
|
}
|
|
|
|
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 == "" && config.Compose().FormatFlowed {
|
|
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()
|
|
var processed bool
|
|
// Color theme changes always need to be propagated to the editor so
|
|
// that it can repaint itself.
|
|
if _, ok := event.(vaxis.ColorThemeUpdate); ok && c.editor != nil {
|
|
processed = c.editor.Event(event)
|
|
}
|
|
if w := c.focusedWidget(); c.editor != nil && w != nil {
|
|
processed = w.Event(event) || processed
|
|
}
|
|
return processed
|
|
}
|
|
|
|
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() {
|
|
var mainCol int = 0
|
|
if c.centeredLayoutWidth != 0 {
|
|
mainCol = 1
|
|
}
|
|
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, mainCol)
|
|
}
|
|
}
|
|
|
|
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()
|
|
var mainCol int = 0
|
|
if c.centeredLayoutWidth != 0 {
|
|
grid = ui.NewGrid().Columns([]ui.GridSpec{
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(c.centeredLayoutWidth)},
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
})
|
|
mainCol = 1
|
|
} else {
|
|
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, mainCol)
|
|
}
|
|
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, mainCol)
|
|
grid.AddChild(c.crypto).At(1, mainCol)
|
|
grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, mainCol)
|
|
if mainCol == 1 {
|
|
grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 0)
|
|
grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 2)
|
|
}
|
|
if c.review != nil {
|
|
grid.AddChild(c.review).At(3, mainCol)
|
|
} else if c.editor != nil {
|
|
grid.AddChild(c.editor).At(3, mainCol)
|
|
}
|
|
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 := max(longest, 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)})
|
|
|
|
var mainCol int = 0
|
|
grid := ui.NewGrid()
|
|
if composer.centeredLayoutWidth != 0 {
|
|
grid = ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
{Strategy: ui.SIZE_EXACT, Size: ui.Const(composer.centeredLayoutWidth)},
|
|
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
|
|
})
|
|
mainCol = 1
|
|
} else {
|
|
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, mainCol)
|
|
} else {
|
|
grid.AddChild(ui.NewText("Send this email?",
|
|
uiConfig.GetStyle(config.STYLE_TITLE))).At(0, mainCol)
|
|
i := 1
|
|
for _, action := range actions {
|
|
grid.AddChild(ui.NewText(action,
|
|
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, mainCol)
|
|
i += 1
|
|
}
|
|
grid.AddChild(ui.NewText("Attachments:",
|
|
uiConfig.GetStyle(config.STYLE_TITLE))).At(i, mainCol)
|
|
i += 1
|
|
if len(composer.attachments) == 0 {
|
|
grid.AddChild(ui.NewText("(none)",
|
|
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, mainCol)
|
|
i += 1
|
|
} else {
|
|
for _, a := range composer.attachments {
|
|
grid.AddChild(ui.NewText(a.Name(), uiConfig.GetStyle(config.STYLE_DEFAULT))).
|
|
At(i, mainCol)
|
|
i += 1
|
|
}
|
|
}
|
|
if len(composer.textParts) > 0 {
|
|
grid.AddChild(ui.NewText("Parts:",
|
|
uiConfig.GetStyle(config.STYLE_TITLE))).At(i, mainCol)
|
|
i += 1
|
|
grid.AddChild(ui.NewText("text/plain", uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, mainCol)
|
|
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, mainCol)
|
|
} else {
|
|
grid.AddChild(ui.NewText(p.MimeType,
|
|
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, mainCol)
|
|
}
|
|
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())
|
|
}
|