aboutsummaryrefslogblamecommitdiffstats
path: root/config/accounts.go
blob: 6748f8a4e2caea442bab7feafb934a28e4e4f0c9 (plain) (tree)
1
2
3
4
5
6
7
8
9
10


              
               





                 
                 

                


                 
                                        
                                             


                               





                                       

































                                                                                         



                                                       

                                                              









                                               

                                           

                                                                                











                                                        

                      




                                                                           
                                                                                 


                                                                           
                                                                                   



                                                                            

                                                                           






                                                                                    
                                                                                    
                                                                                                             



                                                             
                                                                               





                                                                 
                                                             
                                                                        
                                                                                                        
                                                               




                                                                 





                                

                             
                                                                                
                                                                      
 


                                                     
                       
                          
         
 
                                
                                

                                                    
                                                    





                                                             




                                                           


                                                             








                                                                                
                 
                                                                                     







                                                                                      
                                              
                 
 
                                                                                       
                                                    



                                                                           


                                                               
                 








                                                                         
                                           
                                                

                                     
                                                          
                                                                            





                  


















                                                                            






















                                                                                    
                                                                    
         

                                                      
                                
                                                                  



















                                                                      
















                                                   









































                                                                                             



                                                    




                                   

                                                           






                                                                                            
package config

import (
	"bytes"
	"errors"
	"fmt"
	"net/url"
	"os"
	"os/exec"
	"path"
	"reflect"
	"regexp"
	"sort"
	"strings"
	"time"

	"git.sr.ht/~rjarry/aerc/lib/log"
	"github.com/emersion/go-message/mail"
	"github.com/go-ini/ini"
)

var (
	EnablePinentry  func()
	DisablePinentry func()
	SetPinentryEnv  func(*exec.Cmd)
)

type RemoteConfig struct {
	Value       string
	PasswordCmd string
	CacheCmd    bool
	cache       string
}

func (c *RemoteConfig) parseValue() (*url.URL, error) {
	return url.Parse(c.Value)
}

func (c *RemoteConfig) ConnectionString() (string, error) {
	if c.Value == "" || c.PasswordCmd == "" {
		return c.Value, nil
	}

	u, err := c.parseValue()
	if err != nil {
		return "", err
	}

	// ignore the command if a password is specified
	if _, exists := u.User.Password(); exists {
		return c.Value, nil
	}

	// don't attempt to parse the command if the url is a path (ie /usr/bin/sendmail)
	if !u.IsAbs() {
		return c.Value, nil
	}

	pw := c.cache

	if pw == "" {
		usePinentry := EnablePinentry != nil &&
			DisablePinentry != nil &&
			SetPinentryEnv != nil

		cmd := exec.Command("sh", "-c", c.PasswordCmd)
		cmd.Stdin = os.Stdin

		buf := new(bytes.Buffer)
		cmd.Stderr = buf

		if usePinentry {
			EnablePinentry()
			defer DisablePinentry()
			SetPinentryEnv(cmd)
		}

		output, err := cmd.Output()
		if err != nil {
			return "", fmt.Errorf("failed to read password: %v: %w",
				buf.String(), err)
		}
		pw = strings.TrimSpace(string(output))
	}
	u.User = url.UserPassword(u.User.Username(), pw)
	if c.CacheCmd {
		c.cache = pw
	}

	return u.String(), nil
}

type AccountConfig struct {
	Name    string
	Backend string
	// backend specific
	Params map[string]string

	Archive           string          `ini:"archive" default:"Archive"`
	CopyTo            string          `ini:"copy-to"`
	CopyToReplied     bool            `ini:"copy-to-replied" default:"false"`
	Default           string          `ini:"default" default:"INBOX"`
	Postpone          string          `ini:"postpone" default:"Drafts"`
	From              *mail.Address   `ini:"from"`
	UseEnvelopeFrom   bool            `ini:"use-envelope-from" default:"false"`
	Aliases           []*mail.Address `ini:"aliases"`
	Source            string          `ini:"source" parse:"ParseSource"`
	Folders           []string        `ini:"folders" delim:","`
	FoldersExclude    []string        `ini:"folders-exclude" delim:","`
	Headers           []string        `ini:"headers" delim:","`
	HeadersExclude    []string        `ini:"headers-exclude" delim:","`
	Outgoing          RemoteConfig    `ini:"outgoing" parse:"ParseOutgoing"`
	SignatureFile     string          `ini:"signature-file"`
	SignatureCmd      string          `ini:"signature-cmd"`
	EnableFoldersSort bool            `ini:"enable-folders-sort" default:"true"`
	FoldersSort       []string        `ini:"folders-sort" delim:","`
	AddressBookCmd    string          `ini:"address-book-cmd"`
	SendAsUTC         bool            `ini:"send-as-utc" default:"false"`
	SendWithHostname  bool            `ini:"send-with-hostname" default:"false"`
	LocalizedRe       *regexp.Regexp  `ini:"subject-re-pattern" default:"(?i)^((AW|RE|SV|VS|ODP|R): ?)+"`

	// CheckMail
	CheckMail        time.Duration `ini:"check-mail"`
	CheckMailCmd     string        `ini:"check-mail-cmd"`
	CheckMailTimeout time.Duration `ini:"check-mail-timeout" default:"10s"`
	CheckMailInclude []string      `ini:"check-mail-include"`
	CheckMailExclude []string      `ini:"check-mail-exclude"`

	// PGP Config
	PgpKeyId                string `ini:"pgp-key-id"`
	PgpAutoSign             bool   `ini:"pgp-auto-sign"`
	PgpAttachKey            bool   `ini:"pgp-attach-key"`
	PgpOpportunisticEncrypt bool   `ini:"pgp-opportunistic-encrypt"`
	PgpErrorLevel           int    `ini:"pgp-error-level" parse:"ParsePgpErrorLevel" default:"warn"`
	PgpSelfEncrypt          bool   `ini:"pgp-self-encrypt"`

	// AuthRes
	TrustedAuthRes []string `ini:"trusted-authres" delim:","`
}

const (
	PgpErrorLevelNone = iota
	PgpErrorLevelWarn
	PgpErrorLevelError
)

var Accounts []*AccountConfig

func parseAccountsFromFile(root string, accts []string, filename string) error {
	log.Debugf("Parsing accounts configuration from %s", filename)

	file, err := ini.LoadSources(ini.LoadOptions{
		KeyValueDelimiters: "=",
	}, filename)
	if err != nil {
		return err
	}

	starttls_warned := false
	var globals *ini.Section
	for _, _sec := range file.SectionStrings() {
		if _sec == "DEFAULT" {
			globals = file.Section(_sec)
			continue
		}
		if len(accts) > 0 && !contains(accts, _sec) {
			continue
		}
		sec := file.Section(_sec)
		for key, val := range globals.KeysHash() {
			if !sec.HasKey(key) {
				_, _ = sec.NewKey(key, val)
			}
		}

		account, err := ParseAccountConfig(_sec, sec)
		if err != nil {
			log.Errorf("failed to load account [%s]: %s", _sec, err)
			Warnings = append(Warnings, Warning{
				Title: "accounts.conf: error",
				Body: fmt.Sprintf(
					"Failed to load account [%s]:\n\n%s",
					_sec, err,
				),
			})
			continue
		}
		if _, ok := account.Params["smtp-starttls"]; ok && !starttls_warned {
			Warnings = append(Warnings, Warning{
				Title: "accounts.conf: smtp-starttls is deprecated",
				Body: `
SMTP connections now use STARTTLS by default and the smtp-starttls setting is ignored.

If you want to disable STARTTLS, append +insecure to the schema.
`,
			})
			starttls_warned = true
		}

		log.Debugf("accounts.conf: [%s] from = %s", account.Name, account.From)
		Accounts = append(Accounts, account)
	}
	if len(accts) > 0 {
		// Sort accounts struct to match the specified order, if we
		// have one
		var acctnames []string
		for _, acc := range Accounts {
			acctnames = append(acctnames, acc.Name)
		}
		var sortaccts []string
		for _, acc := range accts {
			if contains(acctnames, acc) {
				sortaccts = append(sortaccts, acc)
			} else {
				log.Errorf("account [%s] not found", acc)
			}
		}

		idx := make(map[string]int)
		for i, acct := range sortaccts {
			idx[acct] = i
		}
		sort.Slice(Accounts, func(i, j int) bool {
			return idx[Accounts[i].Name] < idx[Accounts[j].Name]
		})
	}

	return nil
}

func parseAccounts(root string, accts []string, filename string) error {
	if filename == "" {
		filename = path.Join(root, "accounts.conf")
		err := checkConfigPerms(filename)
		if errors.Is(err, os.ErrNotExist) {
			// No config triggers account configuration wizard
			return nil
		} else if err != nil {
			return err
		}
	}

	if err := parseAccountsFromFile(root, accts, filename); err != nil {
		return fmt.Errorf("%s: %w", filename, err)
	}

	return nil
}

func ParseAccountConfig(name string, section *ini.Section) (*AccountConfig, error) {
	account := AccountConfig{
		Name:   name,
		Params: make(map[string]string),
	}
	if err := MapToStruct(section, &account, true); err != nil {
		return nil, err
	}
	for key, val := range section.KeysHash() {
		backendSpecific := true
		typ := reflect.TypeOf(account)
		for i := 0; i < typ.NumField(); i++ {
			field := typ.Field(i)
			if field.Tag.Get("ini") == key {
				backendSpecific = false
				break
			}
		}
		if backendSpecific {
			account.Params[key] = val
		}
	}
	if account.Source == "" {
		return nil, fmt.Errorf("missing 'source' parameter")
	}

	account.Backend = parseBackend(account.Source)
	if account.From == nil {
		return nil, fmt.Errorf("missing 'from' parameter")
	}
	if len(account.Headers) > 0 {
		defaults := []string{
			"date",
			"subject",
			"from",
			"sender",
			"reply-to",
			"to",
			"cc",
			"bcc",
			"in-reply-to",
			"message-id",
			"references",
		}
		account.Headers = append(account.Headers, defaults...)
	}
	return &account, nil
}

func parseBackend(source string) string {
	u, err := url.Parse(source)
	if err != nil {
		return ""
	}
	if strings.HasPrefix(u.Scheme, "imap") {
		return "imap"
	}
	if strings.HasPrefix(u.Scheme, "maildir") {
		return "maildir"
	}
	if strings.HasPrefix(u.Scheme, "jmap") {
		return "jmap"
	}
	return u.Scheme
}

func (a *AccountConfig) ParseSource(sec *ini.Section, key *ini.Key) (string, error) {
	var remote RemoteConfig
	remote.Value = key.String()
	if k, err := sec.GetKey("source-cred-cmd"); err == nil {
		remote.PasswordCmd = k.String()
	}
	return remote.ConnectionString()
}

func (a *AccountConfig) ParseOutgoing(sec *ini.Section, key *ini.Key) (RemoteConfig, error) {
	var remote RemoteConfig
	remote.Value = key.String()
	if k, err := sec.GetKey("outgoing-cred-cmd"); err == nil {
		remote.PasswordCmd = k.String()
	}
	if k, err := sec.GetKey("outgoing-cred-cmd-cache"); err == nil {
		cache, err := k.Bool()
		if err != nil {
			return remote, err
		}
		remote.CacheCmd = cache
	}
	_, err := remote.parseValue()
	return remote, err
}

func (a *AccountConfig) ParsePgpErrorLevel(sec *ini.Section, key *ini.Key) (int, error) {
	var level int
	var err error
	switch strings.ToLower(key.String()) {
	case "none":
		level = PgpErrorLevelNone
	case "warn":
		level = PgpErrorLevelWarn
	case "error":
		level = PgpErrorLevelError
	default:
		err = fmt.Errorf("unknown level: %s", key.String())
	}
	return level, err
}

// checkConfigPerms checks for too open permissions
// printing the fix on stdout and returning an error
func checkConfigPerms(filename string) error {
	info, err := os.Stat(filename)
	if err != nil {
		return err
	}

	perms := info.Mode().Perm()
	if perms&0o44 != 0 && !General.UnsafeAccountsConf {
		// group or others have read access
		fmt.Fprintf(os.Stderr, "The file %v has too open permissions.\n", filename)
		fmt.Fprintln(os.Stderr, "This is a security issue (it contains passwords).")
		fmt.Fprintf(os.Stderr, "To fix it, run `chmod 600 %v`\n", filename)
		return errors.New("account.conf permissions too lax")
	}
	return nil
}