changeset 5:4dff4c3f0fbb

Introduce configuration file where account information is specified Introduce a configuration file where account information must be specified instead of passing it with each invocation on the command line. Each account has a name by which it can be selected and one may be specified as the default account. This is intended to improve usability for productive usage. Enforce strict permissions since passwords may be specified for non-interactive usage. Remove command-line flags for passing account information.
author Guido Berhoerster <guido+sievemgr@berhoerster.name>
date Tue, 03 Nov 2020 23:44:45 +0100
parents f925f15d8ce5
children 2130614cd64a
files cmd/sievemgr/activate.go cmd/sievemgr/common.go cmd/sievemgr/delete.go cmd/sievemgr/get.go cmd/sievemgr/info.go cmd/sievemgr/internal/config/config.go cmd/sievemgr/internal/config/config_test.go cmd/sievemgr/internal/config/parser.go cmd/sievemgr/internal/config/scanner.go cmd/sievemgr/list.go cmd/sievemgr/main.go cmd/sievemgr/put.go
diffstat 12 files changed, 892 insertions(+), 207 deletions(-) [+]
line wrap: on
line diff
--- a/cmd/sievemgr/activate.go	Tue Oct 27 19:17:56 2020 +0100
+++ b/cmd/sievemgr/activate.go	Tue Nov 03 23:44:45 2020 +0100
@@ -21,53 +21,46 @@
 
 package main
 
-import (
-	"net"
-)
-
 func init() {
-	cmdActivate.Flag.StringVar(&username, "u", "", "Set the username")
-	cmdActivate.Flag.StringVar(&passwordFilename, "P", "",
-		"Set the name of the password file")
-	cmdDeactivate.Flag.StringVar(&username, "u", "", "Set the username")
-	cmdDeactivate.Flag.StringVar(&passwordFilename, "P", "",
-		"Set the name of the password file")
+	cmdActivate.Flag.StringVar(&acctName, "a", "", "Select the account")
+	cmdDeactivate.Flag.StringVar(&acctName, "a", "", "Select the account")
 }
 
 var cmdActivate = &command{
-	UsageLine: "activate [options] host[:port] name",
+	UsageLine: "activate [options] name",
 	Run:       runActivate,
 }
 
 var cmdDeactivate = &command{
-	UsageLine: "deactivate [options] host[:port]",
+	UsageLine: "deactivate [options]",
 	Run:       runActivate,
 }
 
 func runActivate(cmd *command, args []string) error {
-	if (cmd.Name() == "activate" && len(args) != 2) ||
-		(cmd.Name() == "deactivate" && len(args) != 1) {
+	if (cmd.Name() == "activate" && len(args) != 1) ||
+		(cmd.Name() == "deactivate" && len(args) != 0) {
 		return usageError("invalid number of arguments")
 	}
 
-	host, port, err := parseHostPort(args[0])
+	var scriptName string
+	if len(args) > 0 {
+		scriptName = args[0]
+	}
+
+	acct, err := getAccount(&conf, acctName)
 	if err != nil {
 		return err
 	}
 
-	var scriptName string
-	if len(args) > 1 {
-		scriptName = args[1]
-	}
-
-	username, password, err := usernamePassword(host, port, username,
-		passwordFilename)
-	if err != nil {
+	if err := lookupHostPort(acct); err != nil {
 		return err
 	}
 
-	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
-		password)
+	if err := readPassword(acct); err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(acct)
 	if err != nil {
 		return err
 	}
--- a/cmd/sievemgr/common.go	Tue Oct 27 19:17:56 2020 +0100
+++ b/cmd/sievemgr/common.go	Tue Nov 03 23:44:45 2020 +0100
@@ -22,7 +22,6 @@
 package main
 
 import (
-	"bufio"
 	"crypto/tls"
 	"errors"
 	"fmt"
@@ -30,44 +29,36 @@
 	"io/ioutil"
 	"net"
 	"os"
-	"os/user"
 	"runtime"
 	"strings"
 
 	"go.guido-berhoerster.org/managesieve"
+	"go.guido-berhoerster.org/sievemgr/cmd/sievemgr/internal/config"
 	"golang.org/x/crypto/ssh/terminal"
 )
 
 var errTooBig = errors.New("too big")
 
-func parseHostPort(s string) (string, string, error) {
-	var host string
-	host, port, err := net.SplitHostPort(s)
-	if err != nil {
-		// error may be due to a missing port but there is no usable
-		// error value to test for, thus try again with a port added
-		var tmpErr error
-		host, _, tmpErr = net.SplitHostPort(s + ":4190")
-		if tmpErr != nil {
-			return "", "", err
+func getAccount(conf *config.Configuration, name string) (*config.Account, error) {
+	if name == "" {
+		if conf.Default == nil {
+			return nil, fmt.Errorf("no default account configured")
+		}
+		return conf.Default, nil
+	}
+	for _, acct := range conf.Accounts {
+		if acct.Name == name {
+			return acct, nil
 		}
 	}
-	if port == "" {
-		// no port given, try to look up a SRV record for given domain
-		// and fall back to the domain and port 4190
-		services, err := managesieve.LookupService(host)
-		if err != nil {
-			return "", "", err
-		}
-		host, port, err = net.SplitHostPort(services[0])
-		if err != nil {
-			return "", "", err
-		}
-	}
-	return host, port, nil
+	return nil, fmt.Errorf("account %q does not exist", name)
 }
 
-func readPassword() (string, error) {
+func readPassword(acct *config.Account) error {
+	if acct.Password != "" {
+		return nil
+	}
+
 	var tty *os.File
 	var fd int
 	var w io.Writer
@@ -78,7 +69,7 @@
 		var err error
 		tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0666)
 		if err != nil {
-			return "", err
+			return err
 		}
 		defer tty.Close()
 		fd = int(tty.Fd())
@@ -89,74 +80,47 @@
 	rawPassword, err := terminal.ReadPassword(fd)
 	io.WriteString(w, "\n")
 	if err != nil {
-		return "", fmt.Errorf("failed to read password: %s", err)
+		return fmt.Errorf("failed to read password: %s", err)
 	}
 	password := string(rawPassword)
 	if password == "" {
-		return "", fmt.Errorf("invalid password")
-	}
-	return password, nil
-}
-
-func readPasswordFile(filename string) (string, error) {
-	f, err := os.Open(filename)
-	if err != nil {
-		return "", err
+		return fmt.Errorf("invalid password")
 	}
-	defer f.Close()
-	scanner := bufio.NewScanner(f)
-	if !scanner.Scan() {
-		err := scanner.Err()
-		if err == nil {
-			err = fmt.Errorf("failed to read from %q: unexpected EOF",
-				filename)
-		}
-		return "", err
-	}
-	password := scanner.Text()
-	if password == "" {
-		return "", fmt.Errorf("invalid password")
-	}
-	return password, nil
+	acct.Password = password
+	return nil
 }
 
-func usernamePassword(host, port, username, passwordFile string) (string, string, error) {
-	// fall back to the system username
-	if username == "" {
-		u, err := user.Current()
-		if err != nil {
-			return "", "",
-				fmt.Errorf("failed to obtain username: %s", err)
-		}
-		username = u.Username
+func lookupHostPort(acct *config.Account) error {
+	if acct.Port != "" {
+		return nil
 	}
-
-	var password string
-	var err error
-	if passwordFile != "" {
-		password, err = readPasswordFile(passwordFilename)
-	} else {
-		password, err = readPassword()
+	// no port given, try to look up a SRV record for given domain
+	// and fall back to the domain and port 4190
+	services, err := managesieve.LookupService(acct.Host)
+	if err != nil {
+		return fmt.Errorf("failed to look up service record: %s", err)
 	}
+	host, port, err := net.SplitHostPort(services[0])
 	if err != nil {
-		return "", "", err
+		return fmt.Errorf("failed to parse service record: %s", err)
 	}
-
-	return username, password, nil
+	acct.Host = host
+	acct.Port = port
+	return nil
 }
 
-func dialPlainAuth(hostport, username, password string) (*managesieve.Client, error) {
-	c, err := managesieve.Dial(hostport)
+func dialPlainAuth(acct *config.Account) (*managesieve.Client, error) {
+	c, err := managesieve.Dial(net.JoinHostPort(acct.Host, acct.Port))
 	if err != nil {
 		return nil, fmt.Errorf("failed to connect: %s", err)
 	}
 
-	host, _, _ := net.SplitHostPort(hostport)
 	// switch to a TLS connection except for localhost
-	if host != "localhost" && host != "127.0.0.1" && host != "::1" {
+	if acct.Host != "localhost" && acct.Host != "127.0.0.1" &&
+		acct.Host != "::1" {
 		tlsConf := &tls.Config{
-			ServerName:         host,
-			InsecureSkipVerify: skipCertVerify,
+			ServerName:         acct.Host,
+			InsecureSkipVerify: acct.Insecure,
 		}
 		if err := c.StartTLS(tlsConf); err != nil {
 			return nil,
@@ -165,10 +129,10 @@
 		}
 	}
 
-	auth := managesieve.PlainAuth("", username, password, host)
+	auth := managesieve.PlainAuth("", acct.User, acct.Password, acct.Host)
 	if err := c.Authenticate(auth); err != nil {
 		return nil, fmt.Errorf("failed to authenticate user %s: %s",
-			username, err)
+			acct.User, err)
 	}
 
 	return c, nil
--- a/cmd/sievemgr/delete.go	Tue Oct 27 19:17:56 2020 +0100
+++ b/cmd/sievemgr/delete.go	Tue Nov 03 23:44:45 2020 +0100
@@ -21,41 +21,36 @@
 
 package main
 
-import (
-	"net"
-)
-
 func init() {
-	cmdDelete.Flag.StringVar(&username, "u", "", "Set the username")
-	cmdDelete.Flag.StringVar(&passwordFilename, "P", "",
-		"Set the name of the password file")
+	cmdDelete.Flag.StringVar(&acctName, "a", "", "Select the account")
 }
 
 var cmdDelete = &command{
-	UsageLine: "delete [options] host[:port] name",
+	UsageLine: "delete [options] name",
 	Run:       runDelete,
 }
 
 func runDelete(cmd *command, args []string) error {
-	if len(args) != 2 {
+	if len(args) != 1 {
 		return usageError("invalid number of arguments")
 	}
 
-	host, port, err := parseHostPort(args[0])
+	scriptName := args[0]
+
+	acct, err := getAccount(&conf, acctName)
 	if err != nil {
 		return err
 	}
 
-	scriptName := args[1]
-
-	username, password, err := usernamePassword(host, port, username,
-		passwordFilename)
-	if err != nil {
+	if err := lookupHostPort(acct); err != nil {
 		return err
 	}
 
-	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
-		password)
+	if err := readPassword(acct); err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(acct)
 	if err != nil {
 		return err
 	}
--- a/cmd/sievemgr/get.go	Tue Oct 27 19:17:56 2020 +0100
+++ b/cmd/sievemgr/get.go	Tue Nov 03 23:44:45 2020 +0100
@@ -22,41 +22,39 @@
 package main
 
 import (
-	"net"
 	"os"
 )
 
 func init() {
-	cmdGet.Flag.StringVar(&username, "u", "", "Set the username")
-	cmdGet.Flag.StringVar(&passwordFilename, "P", "",
-		"Set the name of the password file")
+	cmdGet.Flag.StringVar(&acctName, "a", "", "Select the account")
 }
 
 var cmdGet = &command{
-	UsageLine: "get [options] host[:port] name",
+	UsageLine: "get [options] name",
 	Run:       runGet,
 }
 
 func runGet(cmd *command, args []string) error {
-	if len(args) != 2 {
+	if len(args) != 1 {
 		return usageError("invalid number of arguments")
 	}
 
-	host, port, err := parseHostPort(args[0])
+	scriptName := args[0]
+
+	acct, err := getAccount(&conf, acctName)
 	if err != nil {
 		return err
 	}
 
-	scriptName := args[1]
-
-	username, password, err := usernamePassword(host, port, username,
-		passwordFilename)
-	if err != nil {
+	if err := lookupHostPort(acct); err != nil {
 		return err
 	}
 
-	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
-		password)
+	if err := readPassword(acct); err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(acct)
 	if err != nil {
 		return err
 	}
--- a/cmd/sievemgr/info.go	Tue Oct 27 19:17:56 2020 +0100
+++ b/cmd/sievemgr/info.go	Tue Nov 03 23:44:45 2020 +0100
@@ -27,40 +27,39 @@
 )
 
 func init() {
-	cmdInfo.Flag.StringVar(&username, "u", "", "Set the username")
-	cmdInfo.Flag.StringVar(&passwordFilename, "P", "",
-		"Set the name of the password file")
+	cmdInfo.Flag.StringVar(&acctName, "a", "", "Select the account")
 }
 
 var cmdInfo = &command{
-	UsageLine: "info [options] host[:port]",
+	UsageLine: "info [options]",
 	Run:       runInfo,
 }
 
 func runInfo(cmd *command, args []string) error {
-	if len(args) != 1 {
+	if len(args) != 0 {
 		return usageError("invalid number of arguments")
 	}
 
-	host, port, err := parseHostPort(args[0])
+	acct, err := getAccount(&conf, acctName)
 	if err != nil {
 		return err
 	}
 
-	username, password, err := usernamePassword(host, port, username,
-		passwordFilename)
-	if err != nil {
+	if err := lookupHostPort(acct); err != nil {
 		return err
 	}
 
-	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
-		password)
+	if err := readPassword(acct); err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(acct)
 	if err != nil {
 		return err
 	}
 	defer c.Logout()
 
-	fmt.Printf("%s (%s)\n", net.JoinHostPort(host, port),
+	fmt.Printf("%s (%s)\n", net.JoinHostPort(acct.Host, acct.Port),
 		c.Implementation())
 	if c.SupportsRFC5804() {
 		fmt.Println("RFC5804 supported")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/internal/config/config.go	Tue Nov 03 23:44:45 2020 +0100
@@ -0,0 +1,105 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+sievemgr@berhoerster.name>
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+package config
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+const bom = '\ufeff'
+
+type ParserError struct {
+	Message string
+	LineNo  int
+}
+
+func (e *ParserError) Error() string {
+	if e.LineNo > 0 {
+		return fmt.Sprintf("error in line %d: %s", e.LineNo, e.Message)
+	}
+	return e.Message
+}
+
+func NewParserError(message string, lineNo int) *ParserError {
+	return &ParserError{message, lineNo}
+}
+
+type Configuration struct {
+	Accounts []*Account
+	Default  *Account
+}
+
+type Account struct {
+	Name     string
+	Host     string
+	Port     string
+	User     string
+	Password string
+	Insecure bool
+}
+
+func Parse(data []byte, conf *Configuration) error {
+	rd := bytes.NewReader(data)
+	s := newScanner(bufio.NewReader(rd))
+	p := &parser{s: s}
+	return p.parse(conf)
+}
+
+func DefaultFilename() (string, error) {
+	dir, err := os.UserConfigDir()
+	if err != nil {
+		return "", fmt.Errorf("failed to determine the name of the configuration file: %s", err)
+	}
+	return filepath.Join(dir, "sievemgr", "sievemgr.conf"), nil
+}
+
+func ParseFile(filename string, conf *Configuration) error {
+	f, err := os.Open(filename)
+	if os.IsNotExist(err) {
+		return nil
+	} else if err != nil {
+		return fmt.Errorf("failed to open configuration file: %s", err)
+	}
+	defer f.Close()
+
+	// configutaion file must be a regular file with no more than 0600
+	// permissions
+	info, err := f.Stat()
+	if err != nil {
+		return fmt.Errorf("failed to stat configuration file: %s", err)
+	}
+	mode := info.Mode()
+	if !mode.IsRegular() {
+		return fmt.Errorf("configuration file is not a regular file")
+	}
+	if perm := mode.Perm(); perm&0077 != 0 {
+		return fmt.Errorf("permissions %04o on configuration file are too open", perm)
+	}
+
+	s := newScanner(bufio.NewReader(f))
+	p := &parser{s: s}
+	return p.parse(conf)
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/internal/config/config_test.go	Tue Nov 03 23:44:45 2020 +0100
@@ -0,0 +1,247 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+sievemgr@berhoerster.name>
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+package config_test
+
+import (
+	"testing"
+
+	"go.guido-berhoerster.org/sievemgr/cmd/sievemgr/internal/config"
+)
+
+const basicConfig = "\ufeff" + `account "foo"
+    host "imap.example.net"
+    user "bar@example.net" pass "53cRe7"
+    default
+account "local"
+    host "localhost"
+    port 2000
+    insecure
+    user "foo"
+    pass "s3cR3Et"
+account "bar" host "imap.example.com" user "bar@example.com" pass "53cRe7"
+`
+
+func TestBasicFunctionality(t *testing.T) {
+	var conf config.Configuration
+	if err := config.Parse([]byte(basicConfig), &conf); err != nil {
+		t.Fatalf("failed to parse basic config file: %s", err)
+	}
+	if n := len(conf.Accounts); n != 3 {
+		t.Fatalf("invalid number of parsed accounts, expected 2, got %d", n)
+	}
+	if conf.Accounts[0].Name != "foo" ||
+		conf.Accounts[0].Host != "imap.example.net" ||
+		conf.Accounts[0].Port != "4190" ||
+		conf.Accounts[0].User != "bar@example.net" ||
+		conf.Accounts[0].Password != "53cRe7" ||
+		conf.Accounts[0].Insecure {
+		t.Fatalf(`failed to parse account, expected &main.Account{Name:"foo", Host:"imap.example.net", Port:"4190", User:"bar@example.net", Password:"53cRe7", Insecure:false}, got %#+v`, conf.Accounts[0])
+	}
+	if conf.Default == nil {
+		t.Fatalf("default account not found")
+	}
+	if conf.Default != conf.Accounts[0] {
+		t.Fatalf("wrong default account, expected \"default\", got %q", conf.Default.Name)
+	}
+}
+
+const invalidBOMConfig = "\ufeff\ufeff" + `account "foo" host "imap.example.net" user "bar@example.net" pass "53cRe7" default`
+
+func TestInvalidBOM(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(invalidBOMConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to BOM not at the beginning but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const invalidUTF8Config = `account "foo"` + "\xff" + ` host "imap.example.net" user "bar@example.net" pass "53cRe7" default`
+
+func TestInvalidUTF8(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(invalidUTF8Config), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to invalid UTF-8 but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const nulByteConfig = `account "foo` + "\x00" + `" host "imap.example.net" user "bar@example.net" pass "53cRe7" default`
+
+func TestNulByte(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(nulByteConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to nul byte but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const unexpectedRuneConfig = `account "foo" host "imap.example.net" user "bar@example.net" pass "53cRe7" _in_valid default`
+
+func TestInvalidIdentifier(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(unexpectedRuneConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to unexpected rune but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const unknownIdentifierConfig = `account "foo" host "imap.example.net" user "bar@example.net" pass "53cRe7" invalid default`
+
+func TestUnknownIdentifier(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(unknownIdentifierConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to unknown identifier but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const missingWhitespaceConfig = `account "foo" host "imap.example.net" port 2000default user "bar@example.net" pass "53cRe7"`
+
+func TestMissingSpace(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(missingWhitespaceConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to missing whitespace between tokens but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const missingNameConfig = `account host "imap.example.net" user "bar@example.net" pass "53cRe7" default`
+
+func TestMissingName(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(missingNameConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to missing account name but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const invalidTypeConfig = `account 1234 host "imap.example.net" user "bar@example.net" pass "53cRe7"`
+
+func TestInvalidType(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(invalidTypeConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to invalid type but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const unterminatedStringConfig = `account "foo" host "imap.example.net" user "bar@example.net" pass "53cRe7
+account "bar" host "imap.example.com" user "bar@example.com" pass "53cRe7"
+`
+
+func TestUnterminatedString(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(unterminatedStringConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to an unterminated string but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const unexpectedEOFConfig = `account "foo" host "imap.example.net" user "bar@example.net" pass `
+
+func TestUnexpectedEOF(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(unexpectedEOFConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to an unterminated string but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const missingHostConfig = `account "foo" user "bar@example.net" pass "53cRe7"`
+
+func TestMissingHost(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(missingHostConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to missing host but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
+
+const missingUserConfig = `account "foo" host "imap.example.net" user "" pass "53cRe7"`
+
+func TestMissingUser(t *testing.T) {
+	var conf config.Configuration
+	err := config.Parse([]byte(missingUserConfig), &conf)
+	if err == nil {
+		t.Fatalf("expected error due to missing user but succeeded")
+	}
+	if _, ok := err.(*config.ParserError); !ok {
+		t.Fatalf("expected config.ParserError, got %T (%q)", err,
+			err)
+	}
+	t.Logf("reported error: %s", err)
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/internal/config/parser.go	Tue Nov 03 23:44:45 2020 +0100
@@ -0,0 +1,148 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+sievemgr@berhoerster.name>
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+package config
+
+import (
+	"fmt"
+	"strconv"
+)
+
+type parser struct {
+	s *scanner
+}
+
+func (p *parser) scanType(expectedTok token) (string, error) {
+	tok, lit, err := p.s.scan()
+	if err != nil {
+		return lit, NewParserError(err.Error(), p.s.line)
+	} else if tok != expectedTok {
+		return lit, NewParserError(fmt.Sprintf("expected %s, got %s %q", expectedTok, tok, lit), p.s.line)
+	}
+	return lit, nil
+}
+
+func (p *parser) validateAccount(acct *Account) error {
+	if acct.Host == "" {
+		return NewParserError(fmt.Sprintf("no host specified for account %q",
+			acct.Name), 0)
+	} else if acct.User == "" {
+		return NewParserError(fmt.Sprintf("no user specified for account %q",
+			acct.Name), 0)
+	}
+	return nil
+}
+
+func (p *parser) parse(conf *Configuration) error {
+	var acct *Account
+	var isDefault bool
+	var tok token
+	var lit string
+	var err error
+parsing:
+	for {
+		tok, lit, err = p.s.scan()
+		if err != nil {
+			err = NewParserError(err.Error(), p.s.line)
+			break parsing
+		}
+		if tok == tokenIllegal {
+			err = NewParserError(fmt.Sprintf("illegal token %q", lit), p.s.line)
+			break parsing
+		} else if tok == tokenEOF {
+			break parsing
+		} else if tok != tokenIdent {
+			err = NewParserError(fmt.Sprintf("expected identifier, got %s %q", tok, lit), p.s.line)
+			break parsing
+		}
+		switch lit {
+		case "account":
+			if acct != nil {
+				if err = p.validateAccount(acct); err != nil {
+					break parsing
+				}
+				conf.Accounts = append(conf.Accounts, acct)
+				if isDefault {
+					conf.Default = acct
+					isDefault = false
+				}
+			}
+
+			// account name
+			if lit, err = p.scanType(tokenString); err != nil {
+				break parsing
+			}
+			acct = &Account{
+				Name: lit,
+				Port: "4190",
+			}
+		case "default":
+			isDefault = true
+		case "host":
+			if lit, err = p.scanType(tokenString); err != nil {
+				break parsing
+			}
+			acct.Host = lit
+		case "port":
+			if lit, err = p.scanType(tokenNumber); err != nil {
+				break parsing
+			}
+			var port int
+			if port, err = strconv.Atoi(lit); err != nil {
+				err = NewParserError(fmt.Sprintf("failed to parse port: %s", err), p.s.line)
+				break parsing
+			} else if port < 1 || port > 65535 {
+				err = NewParserError(fmt.Sprintf("invalid port number %d", port), p.s.line)
+				break parsing
+			}
+			acct.Port = lit
+		case "user":
+			if lit, err = p.scanType(tokenString); err != nil {
+				break parsing
+			}
+			acct.User = lit
+		case "pass":
+			if lit, err = p.scanType(tokenString); err != nil {
+				break parsing
+			}
+			acct.Password = lit
+		case "insecure":
+			acct.Insecure = true
+		default:
+			err = NewParserError(fmt.Sprintf("unknown %s: %q", tok, lit), p.s.line)
+			break parsing
+		}
+	}
+	if err != nil {
+		return err
+	}
+	if acct != nil {
+		if err = p.validateAccount(acct); err != nil {
+			return err
+		}
+		conf.Accounts = append(conf.Accounts, acct)
+		if isDefault {
+			conf.Default = acct
+		}
+	}
+
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/internal/config/scanner.go	Tue Nov 03 23:44:45 2020 +0100
@@ -0,0 +1,244 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+sievemgr@berhoerster.name>
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+package config
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"strings"
+	"unicode"
+)
+
+type token int
+
+const (
+	tokenEOF token = -(iota + 1)
+	tokenIllegal
+	tokenIdent
+	tokenNumber
+	tokenString
+)
+
+func (tok token) String() string {
+	switch tok {
+	case tokenIllegal:
+		return "illegal"
+	case tokenEOF:
+		return "EOF"
+	case tokenIdent:
+		return "identifier"
+	case tokenNumber:
+		return "number"
+	case tokenString:
+		return "string"
+	default:
+		return "unknown"
+	}
+}
+
+func isWhitespaceRune(r rune) bool {
+	return r == ' ' || r == '\t' || r == '\r' || r == '\n'
+}
+
+func isIdentRune(r rune) bool {
+	return r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z'
+}
+
+func isNumberRune(r rune) bool {
+	return r >= '0' && r <= '9'
+}
+
+type scanner struct {
+	br       *bufio.Reader
+	line     int  // line number for error messages
+	r        rune // last read rune
+	rdSize   int  // last read size
+	rdOffset int  // offset from beginning of file
+}
+
+func newScanner(br *bufio.Reader) *scanner {
+	return &scanner{line: 1, br: br}
+}
+
+func (s *scanner) read() error {
+	var size int
+again:
+	r, size, err := s.br.ReadRune()
+	if err != nil {
+		return err
+	}
+
+	// skip over BOM at the beginning of the file
+	if r == bom && s.rdOffset == 0 {
+		s.rdOffset += size
+		goto again
+	}
+
+	if s.r == '\n' {
+		s.line++
+	}
+
+	s.r = r
+	s.rdOffset += size
+	s.rdSize = size
+
+	if r == unicode.ReplacementChar && size == 1 {
+		return fmt.Errorf("illegal UTF-8 sequence")
+	} else if r == 0 {
+		return fmt.Errorf("illegal nul byte")
+	}
+	return nil
+}
+
+func (s *scanner) unread() {
+	if s.br.UnreadRune() != nil {
+		return
+	}
+
+	if b, _ := s.br.Peek(1); b[0] == '\n' {
+		// moved back to the previous line
+		s.line--
+	}
+
+	s.r = 0
+	s.rdOffset -= s.rdSize
+	s.rdSize = 0
+}
+
+func (s *scanner) skipWhitespace() error {
+	for {
+		if err := s.read(); err == io.EOF {
+			break
+		} else if err != nil {
+			return err
+		} else if !isWhitespaceRune(s.r) {
+			s.unread()
+			break
+		}
+	}
+	return nil
+}
+
+func (s *scanner) scanIdent() (token, string, error) {
+	var sb strings.Builder
+	for {
+		if err := s.read(); err == io.EOF {
+			break
+		} else if err != nil {
+			return tokenIllegal, "", err
+		}
+		if isIdentRune(s.r) {
+			sb.WriteRune(s.r)
+		} else if isWhitespaceRune(s.r) {
+			s.unread()
+			break
+		} else {
+			return tokenIllegal, "", fmt.Errorf("illegal rune in identifier: %q", s.r)
+		}
+	}
+	if sb.Len() == 0 {
+		return tokenIllegal, "", fmt.Errorf("expected identifier, got %q", s.r)
+	}
+	return tokenIdent, sb.String(), nil
+}
+
+func (s *scanner) scanNumber() (token, string, error) {
+	var sb strings.Builder
+	for {
+		if err := s.read(); err == io.EOF {
+			break
+		} else if err != nil {
+			return tokenIllegal, "", err
+		}
+		if isNumberRune(s.r) {
+			sb.WriteRune(s.r)
+		} else if isWhitespaceRune(s.r) {
+			s.unread()
+			break
+		} else {
+			return tokenIllegal, "", fmt.Errorf("illegal rune in number: %q", s.r)
+		}
+	}
+	if sb.Len() == 0 {
+		return tokenIllegal, "", fmt.Errorf("expected number, got %q", s.r)
+	}
+	return tokenNumber, sb.String(), nil
+}
+
+func (s *scanner) scanString() (token, string, error) {
+	if err := s.read(); err == io.EOF {
+		return tokenIllegal, "", fmt.Errorf("unexpected EOF")
+	} else if err != nil {
+		return tokenIllegal, "", err
+	}
+
+	if s.r != '"' {
+		return tokenIllegal, "",
+			fmt.Errorf("expected '\"', got %q", s.r)
+	}
+
+	var sb strings.Builder
+	var inEscape bool
+	for {
+		if err := s.read(); err == io.EOF {
+			return tokenIllegal, "", fmt.Errorf("unterminated string")
+		} else if err != nil {
+			return tokenIllegal, "", err
+		}
+
+		if s.r == '\\' && !inEscape {
+			inEscape = true
+		} else if s.r == '"' && !inEscape {
+			break
+		} else {
+			sb.WriteRune(s.r)
+			inEscape = false
+		}
+	}
+
+	return tokenString, sb.String(), nil
+}
+
+func (s *scanner) scan() (token, string, error) {
+	if err := s.skipWhitespace(); err != nil {
+		return tokenIllegal, "", err
+	}
+
+	if err := s.read(); err == io.EOF {
+		return tokenEOF, "", nil
+	} else if err != nil {
+		return tokenIllegal, "", err
+	}
+	r := s.r
+	s.unread()
+
+	switch {
+	case isIdentRune(r):
+		return s.scanIdent()
+	case isNumberRune(r):
+		return s.scanNumber()
+	case r == '"':
+		return s.scanString()
+	}
+	return tokenIllegal, string(r), nil
+}
--- a/cmd/sievemgr/list.go	Tue Oct 27 19:17:56 2020 +0100
+++ b/cmd/sievemgr/list.go	Tue Nov 03 23:44:45 2020 +0100
@@ -23,38 +23,36 @@
 
 import (
 	"fmt"
-	"net"
 )
 
 func init() {
-	cmdList.Flag.StringVar(&username, "u", "", "Set the username")
-	cmdList.Flag.StringVar(&passwordFilename, "P", "",
-		"Set the name of the password file")
+	cmdList.Flag.StringVar(&acctName, "a", "", "Select the account")
 }
 
 var cmdList = &command{
-	UsageLine: "list [options] host[:port]",
+	UsageLine: "list [options]",
 	Run:       runList,
 }
 
 func runList(cmd *command, args []string) error {
-	if len(args) != 1 {
+	if len(args) != 0 {
 		return usageError("invalid number of arguments")
 	}
 
-	host, port, err := parseHostPort(args[0])
+	acct, err := getAccount(&conf, acctName)
 	if err != nil {
 		return err
 	}
 
-	username, password, err := usernamePassword(host, port, username,
-		passwordFilename)
-	if err != nil {
+	if err := lookupHostPort(acct); err != nil {
 		return err
 	}
 
-	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
-		password)
+	if err := readPassword(acct); err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(acct)
 	if err != nil {
 		return err
 	}
--- a/cmd/sievemgr/main.go	Tue Oct 27 19:17:56 2020 +0100
+++ b/cmd/sievemgr/main.go	Tue Nov 03 23:44:45 2020 +0100
@@ -26,6 +26,8 @@
 	"flag"
 	"fmt"
 	"os"
+
+	"go.guido-berhoerster.org/sievemgr/cmd/sievemgr/internal/config"
 )
 
 const (
@@ -40,12 +42,23 @@
 	return string(e)
 }
 
+// command-line flags
 var (
-	skipCertVerify   bool
-	username         string
-	passwordFilename string
+	confFilename   string
+	acctName       string
 )
 
+var conf config.Configuration
+
+func init() {
+	var err error
+	confFilename, err = config.DefaultFilename()
+	if err != nil {
+		fmt.Fprintln(flag.CommandLine.Output(), err)
+		os.Exit(exitFailure)
+	}
+}
+
 var cmds = []*command{
 	cmdList,
 	cmdPut,
@@ -71,8 +84,8 @@
 
 func main() {
 	flag.Usage = usage
-	flag.BoolVar(&skipCertVerify, "I", false,
-		"Skip TLS certificate verification")
+	flag.StringVar(&confFilename, "f", confFilename,
+		"Set the name of the configuration file")
 	flag.Parse()
 	if flag.NArg() == 0 {
 		fmt.Fprintln(flag.CommandLine.Output(), "missing subcommand")
@@ -80,6 +93,11 @@
 		os.Exit(exitUsage)
 	}
 
+	if err := config.ParseFile(confFilename, &conf); err != nil {
+		fmt.Fprintln(flag.CommandLine.Output(), err)
+		os.Exit(exitFailure)
+	}
+
 	name := flag.Arg(0)
 	var cmd *command
 	for _, c := range cmds {
--- a/cmd/sievemgr/put.go	Tue Oct 27 19:17:56 2020 +0100
+++ b/cmd/sievemgr/put.go	Tue Nov 03 23:44:45 2020 +0100
@@ -24,60 +24,38 @@
 import (
 	"fmt"
 	"io"
-	"net"
 	"os"
 
 	"go.guido-berhoerster.org/managesieve"
 )
 
 func init() {
-	cmdPut.Flag.StringVar(&username, "u", "", "Set the username")
-	cmdPut.Flag.StringVar(&passwordFilename, "P", "",
-		"Set the name of the password file")
-	cmdCheck.Flag.StringVar(&username, "u", "", "Set the username")
-	cmdCheck.Flag.StringVar(&passwordFilename, "P", "",
-		"Set the name of the password file")
+	cmdPut.Flag.StringVar(&acctName, "a", "", "Select the account")
+	cmdCheck.Flag.StringVar(&acctName, "a", "", "Select the account")
 }
 
 var cmdPut = &command{
-	UsageLine: "put [options] host[:port] name [file]",
+	UsageLine: "put [options] name [file]",
 	Run:       runPut,
 }
 
 var cmdCheck = &command{
-	UsageLine: "check [options] host[:port] [file]",
+	UsageLine: "check [options] [file]",
 	Run:       runPut,
 }
 
 func runPut(cmd *command, args []string) error {
+	var err error
+	acct, err := getAccount(&conf, acctName)
+	if err != nil {
+		return err
+	}
+
 	var scriptName string
 	var r io.Reader = os.Stdin
-	var host, port string
-	var err error
 	if cmd.Name() == "put" {
 		switch len(args) {
-		case 3: // name and filename
-			scriptFile, err := os.Open(args[2])
-			if err != nil {
-				return fmt.Errorf("failed to open script file: %s\n",
-					err)
-			}
-			defer scriptFile.Close()
-			r = scriptFile
-			fallthrough
-		case 2: // only name
-			host, port, err = parseHostPort(args[0])
-			if err != nil {
-				return err
-			}
-
-			scriptName = args[1]
-		default:
-			return usageError("invalid number of arguments")
-		}
-	} else if cmd.Name() == "check" {
-		switch len(args) {
-		case 2: // filename
+		case 2: // name and filename
 			scriptFile, err := os.Open(args[1])
 			if err != nil {
 				return fmt.Errorf("failed to open script file: %s\n",
@@ -86,11 +64,8 @@
 			defer scriptFile.Close()
 			r = scriptFile
 			fallthrough
-		case 1:
-			host, port, err = parseHostPort(args[0])
-			if err != nil {
-				return err
-			}
+		case 1: // only name
+			scriptName = args[0]
 		default:
 			return usageError("invalid number of arguments")
 		}
@@ -115,14 +90,15 @@
 		return fmt.Errorf("failed to read script: %s\n", err)
 	}
 
-	username, password, err := usernamePassword(host, port, username,
-		passwordFilename)
-	if err != nil {
+	if err := lookupHostPort(acct); err != nil {
 		return err
 	}
 
-	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
-		password)
+	if err := readPassword(acct); err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(acct)
 	if err != nil {
 		return err
 	}