aerc

Working clone of aerc-mail.org
git clone git://git.danielmoch.com/aerc.git
Log | Files | Refs | README | LICENSE

config.go (10243B)


      1 package config
      2 
      3 import (
      4 	"errors"
      5 	"fmt"
      6 	"io/ioutil"
      7 	"net/url"
      8 	"os"
      9 	"os/exec"
     10 	"path"
     11 	"regexp"
     12 	"strings"
     13 	"unicode"
     14 
     15 	"github.com/gdamore/tcell"
     16 	"github.com/go-ini/ini"
     17 	"github.com/kyoh86/xdg"
     18 )
     19 
     20 type UIConfig struct {
     21 	IndexFormat       string   `ini:"index-format"`
     22 	TimestampFormat   string   `ini:"timestamp-format"`
     23 	ShowHeaders       []string `delim:","`
     24 	RenderAccountTabs string   `ini:"render-account-tabs"`
     25 	SidebarWidth      int      `ini:"sidebar-width"`
     26 	PreviewHeight     int      `ini:"preview-height"`
     27 	EmptyMessage      string   `ini:"empty-message"`
     28 }
     29 
     30 const (
     31 	FILTER_MIMETYPE = iota
     32 	FILTER_HEADER
     33 )
     34 
     35 type AccountConfig struct {
     36 	CopyTo          string
     37 	Default         string
     38 	From            string
     39 	Name            string
     40 	Source          string
     41 	SourceCredCmd   string
     42 	Folders         []string
     43 	Params          map[string]string
     44 	Outgoing        string
     45 	OutgoingCredCmd string
     46 }
     47 
     48 type BindingConfig struct {
     49 	Global        *KeyBindings
     50 	AccountWizard *KeyBindings
     51 	Compose       *KeyBindings
     52 	ComposeEditor *KeyBindings
     53 	ComposeReview *KeyBindings
     54 	MessageList   *KeyBindings
     55 	MessageView   *KeyBindings
     56 	Terminal      *KeyBindings
     57 }
     58 
     59 type ComposeConfig struct {
     60 	Editor string `ini:"editor"`
     61 }
     62 
     63 type FilterConfig struct {
     64 	FilterType int
     65 	Filter     string
     66 	Command    string
     67 	Header     string
     68 	Regex      *regexp.Regexp
     69 }
     70 
     71 type ViewerConfig struct {
     72 	Pager        string
     73 	Alternatives []string
     74 }
     75 
     76 type AercConfig struct {
     77 	Bindings BindingConfig
     78 	Compose  ComposeConfig
     79 	Ini      *ini.File       `ini:"-"`
     80 	Accounts []AccountConfig `ini:"-"`
     81 	Filters  []FilterConfig  `ini:"-"`
     82 	Viewer   ViewerConfig    `ini:"-"`
     83 	Ui       UIConfig
     84 }
     85 
     86 // Input: TimestampFormat
     87 // Output: timestamp-format
     88 func mapName(raw string) string {
     89 	newstr := make([]rune, 0, len(raw))
     90 	for i, chr := range raw {
     91 		if isUpper := 'A' <= chr && chr <= 'Z'; isUpper {
     92 			if i > 0 {
     93 				newstr = append(newstr, '-')
     94 			}
     95 		}
     96 		newstr = append(newstr, unicode.ToLower(chr))
     97 	}
     98 	return string(newstr)
     99 }
    100 
    101 func loadAccountConfig(path string) ([]AccountConfig, error) {
    102 	file, err := ini.Load(path)
    103 	if err != nil {
    104 		// No config triggers account configuration wizard
    105 		return nil, nil
    106 	}
    107 	file.NameMapper = mapName
    108 
    109 	var accounts []AccountConfig
    110 	for _, _sec := range file.SectionStrings() {
    111 		if _sec == "DEFAULT" {
    112 			continue
    113 		}
    114 		sec := file.Section(_sec)
    115 		account := AccountConfig{
    116 			Default: "INBOX",
    117 			Name:    _sec,
    118 			Params:  make(map[string]string),
    119 		}
    120 		if err = sec.MapTo(&account); err != nil {
    121 			return nil, err
    122 		}
    123 		for key, val := range sec.KeysHash() {
    124 			if key == "folders" {
    125 				account.Folders = strings.Split(val, ",")
    126 			} else if key == "source-cred-cmd" {
    127 				account.SourceCredCmd = val
    128 			} else if key == "outgoing" {
    129 				account.Outgoing = val
    130 			} else if key == "outgoing-cred-cmd" {
    131 				account.OutgoingCredCmd = val
    132 			} else if key == "from" {
    133 				account.From = val
    134 			} else if key == "copy-to" {
    135 				account.CopyTo = val
    136 			} else if key != "name" {
    137 				account.Params[key] = val
    138 			}
    139 		}
    140 		if account.Source == "" {
    141 			return nil, fmt.Errorf("Expected source for account %s", _sec)
    142 		}
    143 
    144 		source, err := parseCredential(account.Source, account.SourceCredCmd)
    145 		if err != nil {
    146 			return nil, fmt.Errorf("Invalid source credentials for %s: %s", _sec, err)
    147 		}
    148 		account.Source = source
    149 
    150 		outgoing, err := parseCredential(account.Outgoing, account.OutgoingCredCmd)
    151 		if err != nil {
    152 			return nil, fmt.Errorf("Invalid outgoing credentials for %s: %s", _sec, err)
    153 		}
    154 		account.Outgoing = outgoing
    155 
    156 		accounts = append(accounts, account)
    157 	}
    158 	return accounts, nil
    159 }
    160 
    161 func parseCredential(cred, command string) (string, error) {
    162 	if cred == "" || command == "" {
    163 		return cred, nil
    164 	}
    165 
    166 	u, err := url.Parse(cred)
    167 	if err != nil {
    168 		return "", err
    169 	}
    170 
    171 	// ignore the command if a password is specified
    172 	if _, exists := u.User.Password(); exists {
    173 		return cred, nil
    174 	}
    175 
    176 	// don't attempt to parse the command if the url is a path (ie /usr/bin/sendmail)
    177 	if !u.IsAbs() {
    178 		return cred, nil
    179 	}
    180 
    181 	cmd := exec.Command("sh", "-c", command)
    182 	output, err := cmd.Output()
    183 	if err != nil {
    184 		return "", fmt.Errorf("failed to read password: %s", err)
    185 	}
    186 
    187 	pw := strings.TrimSpace(string(output))
    188 	u.User = url.UserPassword(u.User.Username(), pw)
    189 
    190 	return u.String(), nil
    191 }
    192 
    193 func installTemplate(root, sharedir, name string) error {
    194 	if _, err := os.Stat(root); os.IsNotExist(err) {
    195 		err := os.MkdirAll(root, 0755)
    196 		if err != nil {
    197 			return err
    198 		}
    199 	}
    200 	data, err := ioutil.ReadFile(path.Join(sharedir, name))
    201 	if err != nil {
    202 		return err
    203 	}
    204 	err = ioutil.WriteFile(path.Join(root, name), data, 0644)
    205 	if err != nil {
    206 		return err
    207 	}
    208 	return nil
    209 }
    210 
    211 func LoadConfig(root *string, sharedir string) (*AercConfig, error) {
    212 	if root == nil {
    213 		_root := path.Join(xdg.ConfigHome(), "aerc")
    214 		root = &_root
    215 	}
    216 	filename := path.Join(*root, "accounts.conf")
    217 	if err := checkConfigPerms(filename); err != nil {
    218 		return nil, err
    219 	}
    220 	filename = path.Join(*root, "aerc.conf")
    221 	file, err := ini.Load(filename)
    222 	if err != nil {
    223 		if err := installTemplate(*root, sharedir, "aerc.conf"); err != nil {
    224 			return nil, err
    225 		}
    226 		if file, err = ini.Load(filename); err != nil {
    227 			return nil, err
    228 		}
    229 	}
    230 	file.NameMapper = mapName
    231 	config := &AercConfig{
    232 		Bindings: BindingConfig{
    233 			Global:        NewKeyBindings(),
    234 			AccountWizard: NewKeyBindings(),
    235 			Compose:       NewKeyBindings(),
    236 			ComposeEditor: NewKeyBindings(),
    237 			ComposeReview: NewKeyBindings(),
    238 			MessageList:   NewKeyBindings(),
    239 			MessageView:   NewKeyBindings(),
    240 			Terminal:      NewKeyBindings(),
    241 		},
    242 		Ini: file,
    243 
    244 		Ui: UIConfig{
    245 			IndexFormat:     "%4C %Z %D %-17.17n %s",
    246 			TimestampFormat: "%F %l:%M %p",
    247 			ShowHeaders: []string{
    248 				"From", "To", "Cc", "Bcc", "Subject", "Date",
    249 			},
    250 			RenderAccountTabs: "auto",
    251 			SidebarWidth:      20,
    252 			PreviewHeight:     12,
    253 			EmptyMessage:      "(no messages)",
    254 		},
    255 	}
    256 	// These bindings are not configurable
    257 	config.Bindings.AccountWizard.ExKey = KeyStroke{
    258 		Key: tcell.KeyCtrlE,
    259 	}
    260 	quit, _ := ParseBinding("<C-q>", ":quit<Enter>")
    261 	config.Bindings.AccountWizard.Add(quit)
    262 	if filters, err := file.GetSection("filters"); err == nil {
    263 		// TODO: Parse the filter more finely, e.g. parse the regex
    264 		for _, match := range filters.KeyStrings() {
    265 			cmd := filters.KeysHash()[match]
    266 			filter := FilterConfig{
    267 				Command: cmd,
    268 				Filter:  match,
    269 			}
    270 			if strings.Contains(match, ",~") {
    271 				filter.FilterType = FILTER_HEADER
    272 				header := filter.Filter[:strings.Index(filter.Filter, ",")]
    273 				regex := filter.Filter[strings.Index(filter.Filter, "~")+1:]
    274 				filter.Header = strings.ToLower(header)
    275 				filter.Regex, err = regexp.Compile(regex)
    276 				if err != nil {
    277 					panic(err)
    278 				}
    279 			} else if strings.ContainsRune(match, ',') {
    280 				filter.FilterType = FILTER_HEADER
    281 				header := filter.Filter[:strings.Index(filter.Filter, ",")]
    282 				value := filter.Filter[strings.Index(filter.Filter, ",")+1:]
    283 				filter.Header = strings.ToLower(header)
    284 				filter.Regex, err = regexp.Compile(regexp.QuoteMeta(value))
    285 			} else {
    286 				filter.FilterType = FILTER_MIMETYPE
    287 			}
    288 			config.Filters = append(config.Filters, filter)
    289 		}
    290 	}
    291 	if viewer, err := file.GetSection("viewer"); err == nil {
    292 		if err := viewer.MapTo(&config.Viewer); err != nil {
    293 			return nil, err
    294 		}
    295 		for key, val := range viewer.KeysHash() {
    296 			switch key {
    297 			case "alternatives":
    298 				config.Viewer.Alternatives = strings.Split(val, ",")
    299 			}
    300 		}
    301 	}
    302 	if compose, err := file.GetSection("compose"); err == nil {
    303 		if err := compose.MapTo(&config.Compose); err != nil {
    304 			return nil, err
    305 		}
    306 	}
    307 	if ui, err := file.GetSection("ui"); err == nil {
    308 		if err := ui.MapTo(&config.Ui); err != nil {
    309 			return nil, err
    310 		}
    311 	}
    312 	accountsPath := path.Join(*root, "accounts.conf")
    313 	if accounts, err := loadAccountConfig(accountsPath); err != nil {
    314 		return nil, err
    315 	} else {
    316 		config.Accounts = accounts
    317 	}
    318 	filename = path.Join(*root, "binds.conf")
    319 	binds, err := ini.Load(filename)
    320 	if err != nil {
    321 		if err := installTemplate(*root, sharedir, "binds.conf"); err != nil {
    322 			return nil, err
    323 		}
    324 		if binds, err = ini.Load(filename); err != nil {
    325 			return nil, err
    326 		}
    327 	}
    328 	groups := map[string]**KeyBindings{
    329 		"default":  &config.Bindings.Global,
    330 		"compose":  &config.Bindings.Compose,
    331 		"messages": &config.Bindings.MessageList,
    332 		"terminal": &config.Bindings.Terminal,
    333 		"view":     &config.Bindings.MessageView,
    334 
    335 		"compose::editor": &config.Bindings.ComposeEditor,
    336 		"compose::review": &config.Bindings.ComposeReview,
    337 	}
    338 	for _, name := range binds.SectionStrings() {
    339 		sec, err := binds.GetSection(name)
    340 		if err != nil {
    341 			return nil, err
    342 		}
    343 		group, ok := groups[strings.ToLower(name)]
    344 		if !ok {
    345 			return nil, errors.New("Unknown keybinding group " + name)
    346 		}
    347 		bindings := NewKeyBindings()
    348 		for key, value := range sec.KeysHash() {
    349 			if key == "$ex" {
    350 				strokes, err := ParseKeyStrokes(value)
    351 				if err != nil {
    352 					return nil, err
    353 				}
    354 				if len(strokes) != 1 {
    355 					return nil, errors.New(
    356 						"Error: only one keystroke supported for $ex")
    357 				}
    358 				bindings.ExKey = strokes[0]
    359 				continue
    360 			}
    361 			if key == "$noinherit" {
    362 				if value == "false" {
    363 					continue
    364 				}
    365 				if value != "true" {
    366 					return nil, errors.New(
    367 						"Error: expected 'true' or 'false' for $noinherit")
    368 				}
    369 				bindings.Globals = false
    370 			}
    371 			binding, err := ParseBinding(key, value)
    372 			if err != nil {
    373 				return nil, err
    374 			}
    375 			bindings.Add(binding)
    376 		}
    377 		*group = MergeBindings(bindings, *group)
    378 	}
    379 	// Globals can't inherit from themselves
    380 	config.Bindings.Global.Globals = false
    381 	return config, nil
    382 }
    383 
    384 // checkConfigPerms checks for too open permissions
    385 // printing the fix on stdout and returning an error
    386 func checkConfigPerms(filename string) error {
    387 	info, err := os.Stat(filename)
    388 	if err != nil {
    389 		return nil // disregard absent files
    390 	}
    391 	perms := info.Mode().Perm()
    392 	goPerms := perms >> 3
    393 	// group or others have read access
    394 	if goPerms&0x44 != 0 {
    395 		fmt.Printf("The file %v has too open permissions.\n", filename)
    396 		fmt.Println("This is a security issue (it contains passwords).")
    397 		fmt.Printf("To fix it, run `chmod 600 %v`\n", filename)
    398 		return errors.New("account.conf permissions too lax")
    399 	}
    400 	return nil
    401 }