package config
import (
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/log"
"github.com/emersion/go-message/mail"
"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 `ini:"archive"`
CopyTo string `ini:"copy-to"`
Default string `ini:"default"`
Postpone string `ini:"postpone"`
From *mail.Address `ini:"-"`
Aliases []*mail.Address `ini:"-"`
Name string `ini:"-"`
Source string `ini:"-"`
Folders []string `ini:"folders" delim:","`
FoldersExclude []string `ini:"folders-exclude" delim:","`
Params map[string]string `ini:"-"`
Outgoing RemoteConfig `ini:"-"`
SignatureFile string `ini:"signature-file"`
SignatureCmd string `ini:"signature-cmd"`
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 `ini:"-"`
// 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"`
PgpErrorLevel int `ini:"pgp-error-level"`
// AuthRes
TrustedAuthRes []string `ini:"trusted-authres" delim:","`
}
const (
PgpErrorLevelNone = iota
PgpErrorLevelWarn
PgpErrorLevelError
)
var Accounts []*AccountConfig
func parseAccounts(root string, accts []string) error {
filename := path.Join(root, "accounts.conf")
if !General.UnsafeAccountsConf {
if err := checkConfigPerms(filename); err != nil {
return err
}
}
log.Debugf("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,
PgpErrorLevel: PgpErrorLevelWarn,
// 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 "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":
addr, err := mail.ParseAddress(val)
if err != nil {
return fmt.Errorf("%s=%s %w", key, val, err)
}
account.From = addr
case "aliases":
addrs, err := mail.ParseAddressList(val)
if err != nil {
return fmt.Errorf("%s=%s %w", key, val, err)
}
account.Aliases = addrs
case "subject-re-pattern":
re, err := regexp.Compile(val)
if err != nil {
return fmt.Errorf("%s=%s %w", key, val, err)
}
account.LocalizedRe = re
case "pgp-error-level":
switch strings.ToLower(val) {
case "none":
account.PgpErrorLevel = PgpErrorLevelNone
case "warn":
account.PgpErrorLevel = PgpErrorLevelWarn
case "error":
account.PgpErrorLevel = PgpErrorLevelError
default:
return fmt.Errorf("unknown pgp-error-level: %s", val)
}
default:
backendSpecific := true
typ := reflect.TypeOf(account)
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
switch field.Tag.Get("ini") {
case key:
fallthrough
case "source":
fallthrough
case "source-cred-cmd":
fallthrough
case "outgoing":
fallthrough
case "outgoing-cred-cmd":
fallthrough
case "outgoing-cred-cmd-cache":
fallthrough
case "subject-re-pattern":
fallthrough
case "pgp-error-level":
backendSpecific = false
}
}
if backendSpecific {
account.Params[key] = val
}
}
}
source, err := sourceRemoteConfig.ConnectionString()
if err != nil {
return fmt.Errorf("Invalid source credentials for %s: %w", _sec, err)
}
account.Source = source
if account.Source == "" {
return fmt.Errorf("Expected source for account %s", _sec)
}
if account.From == nil {
return fmt.Errorf("Expected from for account %s", _sec)
}
_, err = account.Outgoing.parseValue()
if err != nil {
return fmt.Errorf("Invalid outgoing credentials for %s: %w", _sec, err)
}
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
if len(Accounts) != len(accts) {
return errors.New("account(s) not found")
}
sort.Slice(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
}