aboutsummaryrefslogblamecommitdiffstats
path: root/config/accounts.go
blob: e168231f8601fe8055813180a6691c957050f74d (plain) (tree)































































































































































































































































                                                                                               
package config

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

	"git.sr.ht/~rjarry/aerc/logging"
	"github.com/go-ini/ini"
)

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 == "" {
		cmd := exec.Command("sh", "-c", c.PasswordCmd)
		cmd.Stdin = os.Stdin
		output, err := cmd.Output()
		if err != nil {
			return "", fmt.Errorf("failed to read password: %w", 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 {
	Archive           string
	CopyTo            string
	Default           string
	Postpone          string
	From              string
	Aliases           string
	Name              string
	Source            string
	Folders           []string
	FoldersExclude    []string
	Params            map[string]string
	Outgoing          RemoteConfig
	SignatureFile     string
	SignatureCmd      string
	EnableFoldersSort bool     `ini:"enable-folders-sort"`
	FoldersSort       []string `ini:"folders-sort" delim:","`
	AddressBookCmd    string   `ini:"address-book-cmd"`
	SendAsUTC         bool     `ini:"send-as-utc"`
	LocalizedRe       *regexp.Regexp

	// CheckMail
	CheckMail        time.Duration `ini:"check-mail"`
	CheckMailCmd     string        `ini:"check-mail-cmd"`
	CheckMailTimeout time.Duration `ini:"check-mail-timeout"`
	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"`
	PgpOpportunisticEncrypt bool   `ini:"pgp-opportunistic-encrypt"`

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

func (config *AercConfig) parseAccounts(root string, accts []string) error {
	filename := path.Join(root, "accounts.conf")
	if !config.General.UnsafeAccountsConf {
		if err := checkConfigPerms(filename); err != nil {
			return err
		}
	}

	logging.Infof("Parsing accounts configuration from %s", filename)

	file, err := ini.Load(filename)
	if err != nil {
		// No config triggers account configuration wizard
		return nil
	}
	file.NameMapper = mapName

	for _, _sec := range file.SectionStrings() {
		if _sec == "DEFAULT" {
			continue
		}
		if len(accts) > 0 && !contains(accts, _sec) {
			continue
		}
		sec := file.Section(_sec)
		sourceRemoteConfig := RemoteConfig{}
		account := AccountConfig{
			Archive:           "Archive",
			Default:           "INBOX",
			Postpone:          "Drafts",
			Name:              _sec,
			Params:            make(map[string]string),
			EnableFoldersSort: true,
			CheckMailTimeout:  10 * time.Second,
			// localizedRe contains a list of known translations for the common Re:
			LocalizedRe: regexp.MustCompile(`(?i)^((AW|RE|SV|VS|ODP|R): ?)+`),
		}
		if err = sec.MapTo(&account); err != nil {
			return err
		}
		for key, val := range sec.KeysHash() {
			switch key {
			case "folders":
				folders := strings.Split(val, ",")
				sort.Strings(folders)
				account.Folders = folders
			case "folders-exclude":
				folders := strings.Split(val, ",")
				sort.Strings(folders)
				account.FoldersExclude = folders
			case "source":
				sourceRemoteConfig.Value = val
			case "source-cred-cmd":
				sourceRemoteConfig.PasswordCmd = val
			case "outgoing":
				account.Outgoing.Value = val
			case "outgoing-cred-cmd":
				account.Outgoing.PasswordCmd = val
			case "outgoing-cred-cmd-cache":
				cache, err := strconv.ParseBool(val)
				if err != nil {
					return fmt.Errorf("%s=%s %w", key, val, err)
				}
				account.Outgoing.CacheCmd = cache
			case "from":
				account.From = val
			case "aliases":
				account.Aliases = val
			case "copy-to":
				account.CopyTo = val
			case "archive":
				account.Archive = val
			case "enable-folders-sort":
				account.EnableFoldersSort, _ = strconv.ParseBool(val)
			case "pgp-key-id":
				account.PgpKeyId = val
			case "pgp-auto-sign":
				account.PgpAutoSign, _ = strconv.ParseBool(val)
			case "pgp-opportunistic-encrypt":
				account.PgpOpportunisticEncrypt, _ = strconv.ParseBool(val)
			case "address-book-cmd":
				account.AddressBookCmd = val
			case "subject-re-pattern":
				re, err := regexp.Compile(val)
				if err != nil {
					return fmt.Errorf("%s=%s %w", key, val, err)
				}
				account.LocalizedRe = re
			default:
				if key != "name" {
					account.Params[key] = val
				}
			}
		}
		if account.Source == "" {
			return fmt.Errorf("Expected source for account %s", _sec)
		}
		if account.From == "" {
			return fmt.Errorf("Expected from for account %s", _sec)
		}

		source, err := sourceRemoteConfig.ConnectionString()
		if err != nil {
			return fmt.Errorf("Invalid source credentials for %s: %w", _sec, err)
		}
		account.Source = source

		_, err = account.Outgoing.parseValue()
		if err != nil {
			return fmt.Errorf("Invalid outgoing credentials for %s: %w", _sec, err)
		}

		logging.Debugf("accounts.conf: [%s] from = %s", account.Name, account.From)
		config.Accounts = append(config.Accounts, account)
	}
	if len(accts) > 0 {
		// Sort accounts struct to match the specified order, if we
		// have one
		if len(config.Accounts) != len(accts) {
			return errors.New("account(s) not found")
		}
		sort.Slice(config.Accounts, func(i, j int) bool {
			return strings.ToLower(accts[i]) < strings.ToLower(accts[j])
		})
	}

	return nil
}

// 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 errors.Is(err, os.ErrNotExist) {
		return nil // disregard absent files
	}
	if err != nil {
		return err
	}

	perms := info.Mode().Perm()
	// group or others have read access
	if perms&0o44 != 0 {
		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
}