changeset 0:6369453d47a3

Initial revision
author Guido Berhoerster <guido+managesieve@berhoerster.name>
date Thu, 15 Oct 2020 09:11:05 +0200
parents
children eec31eb2d21a
files COPYING README auth.go cmd/sievemgr/activate.go cmd/sievemgr/command.go cmd/sievemgr/common.go cmd/sievemgr/delete.go cmd/sievemgr/get.go cmd/sievemgr/list.go cmd/sievemgr/main.go cmd/sievemgr/put.go example_test.go go.mod go.sum managesieve.go managesieve_test.go parser.go scanner.go
diffstat 18 files changed, 3323 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/COPYING	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,20 @@
+Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,80 @@
+managesieve
+===========
+
+Description
+-----------
+
+The package managesieve implements the ManageSieve protocol as specified in
+RFC 5804.  It covers all mandatory parts of the protocol with the exception of
+the SCRAM-SHA-1 SASL mechanism.  Additional SASL authentication mechanisms can
+be provided by consumers.
+
+A command-line ManageSieve client called sievemgr is included and provides an
+example of how the package may be used.
+
+Usage
+-----
+
+API documentation and usage examples can be displayed using the `go doc`
+command or accessed on [pkg.go.dev][1].
+
+[1]: https://pkg.go.dev/go.guido-berhoerster.org/managesieve
+     "managesieve documentation"
+
+Build Instructions
+------------------
+
+managesieve is a Go module and requires Go version 1.14 or later.  It can be
+used by including its canonical name "go.guido-berhoerster.org/managesieve".
+See the Go documentation for details.
+
+The sievemgr utility can be built using the `go build` command.  See the Go
+documentation for details.
+
+Contact
+-------
+
+Please send any feedback, translations or bug reports via email to
+<guido+managesieve@berhoerster.name>.
+
+Bug Reports
+-----------
+
+When sending bug reports, please always mention the exact version of the
+managesieve package with which the issue occurs as well as the Go
+compiler and version and version of the operating system you are using
+and make sure that you provide sufficient information to reproduce the issue
+and include any input, output, any error messages.
+
+In case of build issues, please also specify the implementations and versions
+of the tools used to build the package and/or program, in particular the Go
+compiler.
+
+In case of crashes, please attach the full backtrace to the bug report.
+
+License
+-------
+
+Except otherwise noted, all files are Copyright (C) 2020 Guido Berhoerster and
+distributed under the following license terms:
+
+Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/auth.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,123 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 managesieve
+
+import (
+	"errors"
+	"fmt"
+)
+
+// this API is inspired by the SASL authentication API in net/smtp
+
+// ServerInfo stores information about the ManageSieve server.
+type ServerInfo struct {
+	Name string   // hostname of the server
+	TLS  bool     // whether a verified TLS connection is used
+	Auth []string // authentication methods advertised in capabilities
+}
+
+// Check whether the server supports the wanted SASL authentication mechanism.
+func (s *ServerInfo) HaveAuth(wanted string) bool {
+	for _, m := range s.Auth {
+		if m == wanted {
+			return true
+		}
+	}
+	return false
+}
+
+type Auth interface {
+	// Initiate SASL authentication.  A non-nil response will be sent in
+	// response to an empty challenge from the server if mandated by the
+	// authentication mechanism.  The name of the SASL authentication
+	// mechanism is returned in mechanism.  If an error is returned SASL
+	// authentication will be aborted and an AuthenticationError will be
+	// returned to the caller.
+	Start(server *ServerInfo) (mechanism string, response []byte, err error)
+	// Handle a challenge received from the server, if more is true the
+	// server expects a response, otherwise the response should be nil. If
+	// an error is returned SASL authentication will be aborted and an
+	// AuthenticationError will be returned to the caller.
+	Next(challenge []byte, more bool) (response []byte, err error)
+}
+
+var (
+	// ErrPlainAuthNotSupported is returned if the server does not support
+	// the SASL PLAIN authentication mechanism.
+	ErrPlainAuthNotSupported = errors.New("the server does not support PLAIN authentication")
+	// ErrPlainAuthTLSRequired is returned when the SASL PLAIN
+	// authentication mechanism is used without TLS against a server other
+	// than localhost.
+	ErrPlainAuthTLSRequired = errors.New("PLAIN authentication requires a TLS connection")
+)
+
+// HostNameVerificationError is returned when the hostname which was passed to
+// the Auth implementation could not be verified against the TLS certificate.
+type HostNameVerificationError struct {
+	ExpectedHost, ActualHost string
+}
+
+func (e *HostNameVerificationError) Error() string {
+	return fmt.Sprintf("host name mismatch: %s != %s", e.ActualHost,
+		e.ExpectedHost)
+}
+
+type plainAuth struct {
+	identity string
+	username string
+	password string
+	host     string
+}
+
+func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) {
+	if !server.HaveAuth("PLAIN") {
+		return "PLAIN", nil, ErrPlainAuthNotSupported
+	}
+
+	// enforce TLS for non-local servers in order to avoid leaking
+	// credentials via unencrypted connections or DNS spoofing
+	if !server.TLS && server.Name != "localhost" &&
+		server.Name != "127.0.0.1" && server.Name != "::1" {
+		return "PLAIN", nil, ErrPlainAuthTLSRequired
+	}
+
+	// verify server hostname before sending credentials
+	if server.Name != a.host {
+		return "PLAIN", nil,
+			&HostNameVerificationError{a.host, server.Name}
+	}
+
+	resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
+	return "PLAIN", resp, nil
+}
+
+func (a *plainAuth) Next(challenge []byte, more bool) ([]byte, error) {
+	return nil, nil
+}
+
+// PlainAuth provides an Auth implementation of SASL PLAIN authentication as
+// specified in RFC 4616 using the provided authorization identity, username
+// and password. If the identity is an empty string the server will derive an
+// identity from the credentials.
+func PlainAuth(identity, username, password, host string) Auth {
+	return &plainAuth{identity, username, password, host}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/activate.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,78 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 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")
+}
+
+var cmdActivate = &command{
+	UsageLine: "activate [options] host[:port] name",
+	Run:       runActivate,
+}
+
+var cmdDeactivate = &command{
+	UsageLine: "deactivate [options] host[:port]",
+	Run:       runActivate,
+}
+
+func runActivate(cmd *command, args []string) error {
+	if (cmd.Name() == "activate" && len(args) != 2) ||
+		(cmd.Name() == "deactivate" && len(args) != 1) {
+		return usageError("invalid number of arguments")
+	}
+
+	host, port, err := parseHostPort(args[0])
+	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 {
+		return err
+	}
+
+	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
+		password)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	if err := c.ActivateScript(scriptName); err != nil {
+		return err
+	}
+
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/command.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,45 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 main
+
+import (
+	"flag"
+	"fmt"
+	"strings"
+)
+
+type command struct {
+	UsageLine string
+	Flag      flag.FlagSet
+	Run       func(cmd *command, args []string) error
+}
+
+func (c *command) Usage() {
+	fmt.Fprintf(flag.CommandLine.Output(), "usage:\n  %s %s\n",
+		flag.CommandLine.Name(), c.UsageLine)
+	fmt.Fprintln(flag.CommandLine.Output(), "options:")
+	c.Flag.PrintDefaults()
+}
+
+func (c *command) Name() string {
+	return strings.SplitN(strings.Trim(c.UsageLine, " "), " ", 2)[0]
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/common.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,192 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 main
+
+import (
+	"bufio"
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net"
+	"os"
+	"os/user"
+	"runtime"
+	"strings"
+
+	"go.guido-berhoerster.org/managesieve"
+	"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
+		}
+	}
+	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
+}
+
+func readPassword() (string, error) {
+	var tty *os.File
+	var fd int
+	var w io.Writer
+	if runtime.GOOS == "windows" {
+		fd = int(os.Stdin.Fd())
+		w = os.Stdout
+	} else {
+		var err error
+		tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0666)
+		if err != nil {
+			return "", err
+		}
+		defer tty.Close()
+		fd = int(tty.Fd())
+		w = tty
+	}
+
+	io.WriteString(w, "Password: ")
+	rawPassword, err := terminal.ReadPassword(fd)
+	io.WriteString(w, "\n")
+	if err != nil {
+		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
+	}
+	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
+}
+
+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
+	}
+
+	var password string
+	var err error
+	if passwordFile != "" {
+		password, err = readPasswordFile(passwordFilename)
+	} else {
+		password, err = readPassword()
+	}
+	if err != nil {
+		return "", "", err
+	}
+
+	return username, password, nil
+}
+
+func dialPlainAuth(hostport, username, password string) (*managesieve.Client, error) {
+	c, err := managesieve.Dial(hostport)
+	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" {
+		tlsConf := &tls.Config{
+			ServerName:         host,
+			InsecureSkipVerify: skipCertVerify,
+		}
+		if err := c.StartTLS(tlsConf); err != nil {
+			return nil,
+				fmt.Errorf("failed to start TLS connection: %s",
+					err)
+		}
+	}
+
+	auth := managesieve.PlainAuth("", username, password, host)
+	if err := c.Authenticate(auth); err != nil {
+		return nil, fmt.Errorf("failed to authenticate user %s: %s",
+			username, err)
+	}
+
+	return c, nil
+}
+
+func readLimitedString(r io.Reader, n int64) (string, error) {
+	var s strings.Builder
+	_, err := io.CopyN(&s, r, n)
+	if err == nil {
+		// check for EOF
+		_, err = io.CopyN(ioutil.Discard, r, 1)
+		if err == nil {
+			return s.String(), errTooBig
+		}
+	}
+	if err != io.EOF {
+		return s.String(), err
+	}
+
+	return s.String(), nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/delete.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,69 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 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")
+}
+
+var cmdDelete = &command{
+	UsageLine: "delete [options] host[:port] name",
+	Run:       runDelete,
+}
+
+func runDelete(cmd *command, args []string) error {
+	if len(args) != 2 {
+		return usageError("invalid number of arguments")
+	}
+
+	host, port, err := parseHostPort(args[0])
+	if err != nil {
+		return err
+	}
+
+	scriptName := args[1]
+
+	username, password, err := usernamePassword(host, port, username,
+		passwordFilename)
+	if err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
+		password)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	if err := c.DeleteScript(scriptName); err != nil {
+		return err
+	}
+
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/get.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,72 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 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")
+}
+
+var cmdGet = &command{
+	UsageLine: "get [options] host[:port] name",
+	Run:       runGet,
+}
+
+func runGet(cmd *command, args []string) error {
+	if len(args) != 2 {
+		return usageError("invalid number of arguments")
+	}
+
+	host, port, err := parseHostPort(args[0])
+	if err != nil {
+		return err
+	}
+
+	scriptName := args[1]
+
+	username, password, err := usernamePassword(host, port, username,
+		passwordFilename)
+	if err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
+		password)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	script, err := c.GetScript(scriptName)
+	if err != nil {
+		return err
+	}
+	os.Stdout.WriteString(script)
+
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/list.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,77 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 main
+
+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")
+}
+
+var cmdList = &command{
+	UsageLine: "list [options] host[:port]",
+	Run:       runList,
+}
+
+func runList(cmd *command, args []string) error {
+	if len(args) != 1 {
+		return usageError("invalid number of arguments")
+	}
+
+	host, port, err := parseHostPort(args[0])
+	if err != nil {
+		return err
+	}
+
+	username, password, err := usernamePassword(host, port, username,
+		passwordFilename)
+	if err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
+		password)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	scripts, active, _ := c.ListScripts()
+	if err != nil {
+		return err
+	}
+	fmt.Println("ACTIVE  SCRIPT")
+	for _, script := range scripts {
+		isActive := "no"
+		if script == active {
+			isActive = "yes"
+		}
+		fmt.Printf("%-3s     %s\n", isActive, script)
+	}
+
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/main.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,115 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 main
+
+import (
+	"errors"
+	"flag"
+	"fmt"
+	"os"
+)
+
+const (
+	exitSuccess = iota
+	exitFailure
+	exitUsage
+)
+
+type usageError string
+
+func (e usageError) Error() string {
+	return string(e)
+}
+
+var (
+	skipCertVerify   bool
+	username         string
+	passwordFilename string
+)
+
+var cmds = []*command{
+	cmdList,
+	cmdPut,
+	cmdGet,
+	cmdActivate,
+	cmdDeactivate,
+	cmdDelete,
+}
+
+func usage() {
+	fmt.Fprintf(flag.CommandLine.Output(),
+		"usage:\n  %s [options] [subcommand [options] [arguments]]\n",
+		flag.CommandLine.Name())
+	fmt.Fprintln(flag.CommandLine.Output(), "subcommands:")
+	for _, cmd := range cmds {
+		fmt.Fprintf(flag.CommandLine.Output(), "  %s\n", cmd.Name())
+	}
+	fmt.Fprintln(flag.CommandLine.Output(), "global options:")
+	flag.PrintDefaults()
+}
+
+func main() {
+	flag.Usage = usage
+	flag.BoolVar(&skipCertVerify, "I", false,
+		"Skip TLS certificate verification")
+	flag.Parse()
+	if flag.NArg() == 0 {
+		fmt.Fprintln(flag.CommandLine.Output(), "missing subcommand")
+		usage()
+		os.Exit(exitUsage)
+	}
+
+	name := flag.Arg(0)
+	var cmd *command
+	for _, c := range cmds {
+		if c.Name() == name {
+			cmd = c
+			break
+		}
+	}
+	if cmd == nil {
+		fmt.Fprintf(flag.CommandLine.Output(),
+			"unknown subcommand %q\n", name)
+		usage()
+		os.Exit(exitUsage)
+	}
+
+	cmd.Flag.Init(cmd.Name(), flag.ExitOnError)
+	cmd.Flag.Usage = cmd.Usage
+	args := flag.Args()
+	if err := cmd.Flag.Parse(args[1:]); err != nil {
+		fmt.Fprintln(flag.CommandLine.Output(), err)
+		os.Exit(exitFailure)
+	}
+
+	if err := cmd.Run(cmd, cmd.Flag.Args()); err != nil {
+		fmt.Fprintln(flag.CommandLine.Output(), err)
+
+		var uerr usageError
+		if errors.As(err, &uerr) {
+			cmd.Usage()
+			os.Exit(exitUsage)
+		}
+
+		os.Exit(exitFailure)
+	}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/sievemgr/put.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,93 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 main
+
+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")
+}
+
+var cmdPut = &command{
+	UsageLine: "put [options] host[:port] name [file]",
+	Run:       runPut,
+}
+
+func runPut(cmd *command, args []string) error {
+	var scriptName string
+	var r io.Reader = os.Stdin
+	var host, port string
+	var err error
+	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")
+	}
+
+	script, err := readLimitedString(r, managesieve.ReadLimit)
+	if err != nil {
+		return fmt.Errorf("failed to read script: %s\n", err)
+	}
+
+	username, password, err := usernamePassword(host, port, username,
+		passwordFilename)
+	if err != nil {
+		return err
+	}
+
+	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
+		password)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	if err := c.PutScript(scriptName, script); err != nil {
+		return err
+	}
+
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/example_test.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,133 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 managesieve_test
+
+import (
+	"crypto/tls"
+	"fmt"
+	"log"
+
+	"go.guido-berhoerster.org/managesieve"
+)
+
+func Example() {
+	host := "mail.example.org"
+	username := "foo"
+	password := "S3cR3T"
+	script := `require "fileinto";
+
+if address :is "from" "foo@example.com" {
+    fileinto "INBOX/foo";
+}
+`
+	scriptName := "newscript"
+
+	// Try to look up a SRV record for given domain and fall back to the
+	// domain and port 4190.
+	var hostport string
+	if services, err := managesieve.LookupService(host); err != nil {
+		hostport = host + ":4190"
+	} else {
+		hostport = services[0]
+	}
+
+	// Connect to the ManageSieve server.
+	c, err := managesieve.Dial(hostport)
+	if err != nil {
+		log.Fatalf("failed to connect: %s", err)
+	}
+
+	// Establish a TLS connection.
+	tlsConf := &tls.Config{ServerName: host}
+	if err := c.StartTLS(tlsConf); err != nil {
+		log.Fatalf("failed to start TLS connection: %s", err)
+	}
+
+	// Authenticate the user using the PLAIN SASL mechanism.
+	auth := managesieve.PlainAuth("", username, password, host)
+	if err := c.Authenticate(auth); err != nil {
+		log.Fatalf("failed to authenticate user %s: %s", username, err)
+	}
+
+	// Check the validity of the script.
+	if err = c.CheckScript(script); err != nil {
+		log.Fatalf("script %q is not valid: %s", scriptName, err)
+	}
+
+	// Check whether ther is sufficient space for uploading the script.
+	if ok, err := c.HaveSpace(scriptName, int64(len(script))); err != nil {
+		log.Fatalf("failed to determine whether there is enough space: %s",
+			err)
+	} else if !ok {
+		log.Fatalf("not enough space to upload script %q", scriptName)
+	}
+
+	// Upload the script.
+	if err = c.PutScript(scriptName, script); err != nil {
+		log.Fatalf("failed to upload script %q: %s", scriptName, err)
+	}
+
+	// Activate the uploaded script
+	if err = c.ActivateScript(scriptName); err != nil {
+		log.Fatalf("failed to set script %q active: %s", scriptName,
+			err)
+	}
+
+	// Get a list of the names of all scripts on the server and determine
+	// the currently active script.
+	scripts, active, err := c.ListScripts()
+	if err != nil {
+		log.Fatalf("failed to list scripts: %s", err)
+	}
+	if active != scriptName {
+		log.Fatalf("%q is not the active script", scriptName)
+	}
+	// Download each script from the list.
+	for _, name := range scripts {
+		if name == active {
+			fmt.Printf("%q:\n", name)
+		} else {
+			fmt.Printf("%q (active):\n", name)
+		}
+
+		content, err := c.GetScript(name)
+		if err != nil {
+			log.Fatalf("failed to get script %q: %s", name, err)
+		}
+		fmt.Println(content)
+	}
+
+	// Rename the script to "temp".
+	if err = c.RenameScript(scriptName, "temp"); err != nil {
+		log.Fatalf("RENAMESCRIPT failed: %s", err)
+	}
+
+	// Delete the script.
+	if err = c.DeleteScript("temp"); err != nil {
+		log.Fatalf("DELETESCRIPT failed: %s", err)
+	}
+
+	// Log out and close the connection.
+	if err = c.Logout(); err != nil {
+		log.Fatalf("failed to log out: %s", err)
+	}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/go.mod	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,8 @@
+module go.guido-berhoerster.org/managesieve
+
+go 1.14
+
+require (
+	golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899
+	golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/go.sum	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,10 @@
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg=
+golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/managesieve.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,546 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 managesieve implements the MANAGESIEVE protocol as specified in
+// RFC 5804.  It covers all mandatory parts of the protocol with the exception
+// of the SCRAM-SHA-1 SASL mechanism.  Additional SASL authentication
+// mechanisms can be provided by consumers.
+package managesieve
+
+import (
+	"crypto/tls"
+	"encoding/base64"
+	"fmt"
+	"math"
+	"net"
+	"strconv"
+	"strings"
+)
+
+// ParserError represents a syntax error encountered while parsing a response
+// from the server.
+type ParserError string
+
+func (e ParserError) Error() string {
+	return "parse error: " + string(e)
+}
+
+// ProtocolError represents a MANAGESIEVE protocol violation.
+type ProtocolError string
+
+func (e ProtocolError) Error() string {
+	return "protocol error: " + string(e)
+}
+
+// NotSupportedError is returned if an operation requires an extension that is
+// not available.
+type NotSupportedError string
+
+func (e NotSupportedError) Error() string {
+	return "not supported: " + string(e)
+}
+
+// AuthenticationError is returned if an authentication attempt has failed.
+type AuthenticationError string
+
+func (e AuthenticationError) Error() string {
+	return "authentication failed: " + string(e)
+}
+
+// A ServerError is represents an error returned by the server in the form of a
+// NO response.
+type ServerError struct {
+	Code string // optional response code of the error
+	Msg  string // optional human readable error message
+}
+
+func (e *ServerError) Error() string {
+	if e.Msg != "" {
+		return e.Msg
+	}
+	return "unspecified server error"
+}
+
+// The ConnClosedError is returned if the server has closed the connection.
+type ConnClosedError struct {
+	Code string
+	Msg  string
+}
+
+func (e *ConnClosedError) Error() string {
+	msg := "the server has closed to connection"
+	if e.Msg != "" {
+		return msg + ": " + e.Msg
+	}
+	return msg
+}
+
+// Tries to look up the MANAGESIEVE SRV record for the domain and returns an
+// slice of strings containing hostnames and ports. If no SRV record was found
+// it falls back to the given domain name and port 4190.
+func LookupService(domain string) ([]string, error) {
+	_, addrs, err := net.LookupSRV("sieve", "tcp", domain)
+	if err != nil {
+		if dnserr, ok := err.(*net.DNSError); ok {
+			if dnserr.IsNotFound {
+				// no SRV record found, fall back to port 4190
+				services := [1]string{domain + ":4190"}
+				return services[:], nil
+			}
+		}
+		return nil, err
+	}
+	services := make([]string, len(addrs))
+	// addrs is already ordered by priority
+	for _, addr := range addrs {
+		services = append(services,
+			fmt.Sprintf("%s:%d", addr.Target, addr.Port))
+	}
+	return services, nil
+}
+
+// Checks whtether the given string conforms to the "Unicode Format for Network
+// Interchange" specified in RFC 5198.
+func IsNetUnicode(s string) bool {
+	for _, c := range s {
+		if c <= 0x1f || (c >= 0x7f && c <= 0x9f) ||
+			c == 0x2028 || c == 0x2029 {
+			return false
+		}
+	}
+	return true
+}
+
+func quoteString(s string) string {
+	return fmt.Sprintf("{%d+}\r\n%s", len(s), s)
+}
+
+// Client represents a client connection to a MANAGESIEVE server.
+type Client struct {
+	conn       net.Conn
+	p          *parser
+	isAuth     bool
+	capa       map[string]string
+	serverName string
+}
+
+// Dial creates a new connection to a MANAGESIEVE server. The given addr must
+// contain both a hostname or IP address and post.
+func Dial(addr string) (*Client, error) {
+	host, _, err := net.SplitHostPort(addr)
+	if err != nil {
+		return nil, err
+	}
+	conn, err := net.Dial("tcp", addr)
+	if err != nil {
+		return nil, err
+	}
+	return NewClient(conn, host)
+}
+
+// NewClient create a new client based on an existing connection to a
+// MANAGESIEVE server where host specifies the hostname of the remote end of
+// the connection.
+func NewClient(conn net.Conn, host string) (*Client, error) {
+	s := newScanner(conn)
+	p := &parser{s}
+	c := &Client{
+		conn:       conn,
+		p:          p,
+		serverName: host,
+	}
+
+	r, err := c.p.readReply()
+	if err != nil {
+		c.Close()
+		return nil, err
+	}
+	switch r.resp {
+	case responseOk:
+		c.capa, err = parseCapabilities(r)
+	case responseNo:
+		return c, &ServerError{r.code, r.msg}
+	case responseBye:
+		return c, &ConnClosedError{r.code, r.msg}
+	}
+	return c, err
+}
+
+// SupportsRFC5804 returns true if the server conforms to RFC 5804.
+func (c *Client) SupportsRFC5804() bool {
+	_, ok := c.capa["VERSION"]
+	return ok
+}
+
+// SupportsTLS returns true if the server supports TLS connections via the
+// STARTTLS command.
+func (c *Client) SupportsTLS() bool {
+	_, ok := c.capa["STARTTLS"]
+	return ok
+}
+
+// Extensions returns the Sieve script extensions supported by the Sieve engine.
+func (c *Client) Extensions() []string {
+	return strings.Fields(c.capa["SIEVE"])
+}
+
+// MaxRedirects returns the limit on the number of Sieve "redirect" during a
+// single evaluation.
+func (c *Client) MaxRedirects() int {
+	n, err := strconv.ParseUint(c.capa["MAXREDIRECTS"], 10, 32)
+	if err != nil {
+		return 0
+	}
+	return int(n)
+}
+
+// NotifyMethods returns the URI schema parts for supported notification
+// methods.
+func (c *Client) NotifyMethods() []string {
+	return strings.Fields(c.capa["NOTIFY"])
+}
+
+// SASLMechanisms returns the SASL authentication mechanism supported by the
+// server. This may change depending on whether a TLS connection is used.
+func (c *Client) SASLMechanisms() []string {
+	splitFunc := func(r rune) bool {
+		return r == ' '
+	}
+	return strings.FieldsFunc(c.capa["SASL"], splitFunc)
+}
+
+func (c *Client) cmd(args ...interface{}) (*reply, error) {
+	// write each arg separated by a space and terminated by CR+LF
+	for i, arg := range args {
+		if i > 0 {
+			if _, err := c.conn.Write([]byte{' '}); err != nil {
+				return nil, err
+			}
+		}
+		if _, err := fmt.Fprint(c.conn, arg); err != nil {
+			return nil, err
+		}
+	}
+	if _, err := c.conn.Write([]byte("\r\n")); err != nil {
+		return nil, err
+	}
+
+	r, err := c.p.readReply()
+	if err != nil {
+		return nil, err
+	}
+	if r.resp == responseNo {
+		return r, &ServerError{r.code, r.msg}
+	} else if r.resp == responseBye {
+		return r, &ConnClosedError{r.code, r.msg}
+	}
+	return r, nil
+}
+
+// StartTLS upgrades the connection to use TLS encryption based on the given
+// configuration.
+func (c *Client) StartTLS(config *tls.Config) error {
+	if _, ok := c.conn.(*tls.Conn); ok {
+		return ProtocolError("already using a TLS connection")
+	}
+	if c.isAuth {
+		return ProtocolError("cannot STARTTLS in authenticated state")
+	}
+	if _, ok := c.capa["STARTTLS"]; !ok {
+		return NotSupportedError("STARTTLS")
+	}
+	if _, err := c.cmd("STARTTLS"); err != nil {
+		return err
+	}
+	c.conn = tls.Client(c.conn, config)
+	s := newScanner(c.conn)
+	c.p = &parser{s}
+
+	r, err := c.p.readReply()
+	if err != nil {
+		return err
+	}
+	// capabilities are no longer valid if STARTTLS succeeded
+	c.capa, err = parseCapabilities(r)
+	return err
+}
+
+// TLSConnectionState returns the ConnectionState of the current TLS
+// connection.
+func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) {
+	tc, ok := c.conn.(*tls.Conn)
+	if !ok {
+		return
+	}
+	return tc.ConnectionState(), ok
+}
+
+// Authenticate authenticates a client using the given authentication
+// mechanism. In case of an AuthenticationError the client remains in a defined
+// state and can continue to be used.
+func (c *Client) Authenticate(a Auth) error {
+	encoding := base64.StdEncoding
+	_, isTLS := c.conn.(*tls.Conn)
+	info := &ServerInfo{c.serverName, isTLS, c.SASLMechanisms()}
+	mech, resp, err := a.Start(info)
+	if err != nil {
+		return err
+	}
+	if _, err = fmt.Fprintf(c.conn, "AUTHENTICATE \"%s\" \"%s\"\r\n",
+		mech, encoding.EncodeToString(resp)); err != nil {
+		return err
+	}
+
+	var line []*token
+	// handle SASL challenge-response messages exchanged as base64-encoded
+	// strings until the server sends a MANAGESIEVE response which may
+	// contain some final SASL data
+	for {
+		line, err = c.p.readLine()
+		if err != nil {
+			return err
+		}
+
+		if c.p.isResponseLine(line) {
+			break
+		} else if len(line) != 1 ||
+			(line[0].typ != tokenQuotedString &&
+				line[0].typ != tokenLiteralString) {
+			return ParserError("failed to parse SASL data: expected string")
+		}
+		msg, err := encoding.DecodeString(line[0].literal)
+		if err != nil {
+			return ParserError("failed to decode SASL data: " +
+				err.Error())
+		}
+
+		// perform next step in authentication process
+		resp, authErr := a.Next(msg, true)
+		if authErr != nil {
+			// this error should be recoverable, abort
+			// authentication
+			if _, err := fmt.Fprintf(c.conn, "\"*\"\r\n"); err != nil {
+				return err
+			}
+
+			line, err = c.p.readLine()
+			if err != nil {
+				return err
+			}
+			if r, err := c.p.parseResponseLine(line); err != nil {
+				return err
+			} else if r.resp != responseNo {
+				return ProtocolError("invalid response to aborted authentication: expected NO")
+			}
+
+			return AuthenticationError(authErr.Error())
+		}
+
+		// send SASL response
+		if _, err := fmt.Fprintf(c.conn, "\"%s\"\r\n",
+			encoding.EncodeToString(resp)); err != nil {
+			return err
+		}
+	}
+
+	// handle MANAGESIEVE response
+	r, err := c.p.parseResponseLine(line)
+	if err != nil {
+		return err
+	}
+	if r.resp == responseNo {
+		return AuthenticationError(r.msg)
+	} else if r.resp == responseBye {
+		return &ConnClosedError{r.code, r.msg}
+	}
+
+	// check for SASL response code with final SASL data as the response
+	// code argument
+	if r.code == "SASL" {
+		if len(r.codeArgs) != 1 {
+			return ParserError("failed to parse SASL code argument: expected a single argument")
+		}
+		msg64 := r.codeArgs[0]
+		msg, err := encoding.DecodeString(msg64)
+		if err != nil {
+			return ParserError("failed to decode SASL code argument: " + err.Error())
+		}
+
+		if _, err = a.Next(msg, false); err != nil {
+			return AuthenticationError(err.Error())
+		}
+	}
+
+	// capabilities are no longer valid after succesful authentication
+	r, err = c.cmd("CAPABILITY")
+	if err != nil {
+		return err
+	}
+	c.capa, err = parseCapabilities(r)
+	return err
+}
+
+// HaveSpace queries the server if there is sufficient space to store a script
+// with the given name and size.  An already existing script with the same name
+// will be treated as if it were replaced with a script of the given size.
+func (c *Client) HaveSpace(name string, size int64) (bool, error) {
+	if size < 0 {
+		return false,
+			ProtocolError(fmt.Sprintf("invalid script size: %d",
+				size))
+	}
+	if size > math.MaxInt32 {
+		return false, ProtocolError("script exceeds maximum size")
+	}
+	r, err := c.cmd("HAVESPACE", quoteString(name), size)
+	if err != nil {
+		if r.code == "QUOTA" || r.code == "QUOTA/MAXSIZE" {
+			err = nil
+		}
+	}
+	return r.resp == responseOk, err
+}
+
+// PutScript stores the script content with the given name on the server.  An
+// already existing script with the same name will be replaced.
+func (c *Client) PutScript(name, content string) error {
+	if !IsNetUnicode(name) {
+		return ProtocolError("script name must comply with Net-Unicode")
+	}
+	_, err := c.cmd("PUTSCRIPT", quoteString(name), quoteString(content))
+	return err
+}
+
+// ListScripts returns the names of all scripts on the server and the name of
+// the currently active script.  If there is no active script it returns the
+// empty string.
+func (c *Client) ListScripts() ([]string, string, error) {
+	r, err := c.cmd("LISTSCRIPTS")
+	if err != nil {
+		return nil, "", err
+	}
+
+	var scripts []string = make([]string, 0)
+	var active string
+	for _, tokens := range r.lines {
+		if tokens[0].typ != tokenQuotedString &&
+			tokens[0].typ != tokenLiteralString {
+			return nil, "", ParserError("failed to parse script list: expected string")
+		}
+		switch len(tokens) {
+		case 2:
+			if tokens[1].typ != tokenAtom ||
+				tokens[1].literal != "ACTIVE" {
+				return nil, "", ParserError("failed to parse script list: expected atom ACTIVE")
+			}
+			active = tokens[0].literal
+			fallthrough
+		case 1:
+			scripts = append(scripts, tokens[0].literal)
+		default:
+			return nil, "", ParserError("failed to parse script list: trailing data")
+		}
+	}
+	return scripts, active, nil
+}
+
+// ActivateScript activates a script. Only one script can be active at the same
+// time, activating a script will deactivate the previously active script. If
+// the name is the empty string the currently active script will be
+// deactivated.
+func (c *Client) ActivateScript(name string) error {
+	_, err := c.cmd("SETACTIVE", quoteString(name))
+	return err
+}
+
+// GetScript returns the content of the script with the given name.
+func (c *Client) GetScript(name string) (string, error) {
+	r, err := c.cmd("GETSCRIPT", quoteString(name))
+	if err != nil {
+		return "", err
+	}
+	if len(r.lines) != 1 ||
+		(r.lines[0][0].typ != tokenQuotedString &&
+			r.lines[0][0].typ != tokenLiteralString) {
+		return "", ParserError("failed to parse script: expected string")
+	}
+	return r.lines[0][0].literal, nil
+}
+
+// DeleteScript deletes the script with the given name from the server.
+func (c *Client) DeleteScript(name string) error {
+	_, err := c.cmd("DELETESCRIPT", quoteString(name))
+	return err
+}
+
+// RenameScript renames a script on the server. This operation is only
+// available if the server conforms to RFC 5804.
+func (c *Client) RenameScript(oldName, newName string) error {
+	if !c.SupportsRFC5804() {
+		return NotSupportedError("RENAMESCRIPT")
+	}
+	if !IsNetUnicode(newName) {
+		return ProtocolError("script name must comply with Net-Unicode")
+	}
+	_, err := c.cmd("RENAMESCRIPT", quoteString(oldName),
+		quoteString(newName))
+	return err
+}
+
+// CheckScript checks if the given script contains any errors. This operation
+// is only available if the server conforms to RFC 5804.
+func (c *Client) CheckScript(content string) error {
+	if !c.SupportsRFC5804() {
+		return NotSupportedError("CHECKSCRIPT")
+	}
+	_, err := c.cmd("CHECKSCRIPT", quoteString(content))
+	return err
+}
+
+// Noop does nothing but contact the server and can be used to prevent timeouts
+// and to check whether the connection is still alive. This operation is only
+// available if the server conforms to RFC 5804.
+func (c *Client) Noop() error {
+	if !c.SupportsRFC5804() {
+		return NotSupportedError("NOOP")
+	}
+	_, err := c.cmd("NOOP")
+	return err
+}
+
+// Close closes the connection to the server immediately without informing the
+// remote end that the client has finished.  Under normal circumstances Logout
+// should be used instead.
+func (c *Client) Close() error {
+	return c.conn.Close()
+}
+
+// Logout first indicates to the server that the client is finished and
+// subsequently closes the connection. No further commands can be sent after
+// this.
+func (c *Client) Logout() error {
+	_, err := c.cmd("LOGOUT")
+	cerr := c.Close()
+	if err == nil {
+		err = cerr
+	}
+	return err
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/managesieve_test.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,1189 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 managesieve_test
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"strings"
+	"testing"
+	"time"
+
+	"go.guido-berhoerster.org/managesieve"
+)
+
+func quoteClientString(s string) string {
+	return fmt.Sprintf("{%d+}\r\n%s", len(s), s)
+}
+
+func quoteServerString(s string) string {
+	return fmt.Sprintf("{%d}\r\n%s", len(s), s)
+}
+
+// turn LF into CRLF line endeings and substitute passed key-value string pairs
+func fixClientOutput(s string, replacements ...string) string {
+	return strings.NewReplacer(append(replacements,
+		"\n", "\r\n")...).Replace(s)
+}
+
+type fakeConn struct {
+	io.ReadWriter
+	buf bytes.Buffer
+	w   *bufio.Writer
+}
+
+func newFakeConn(s string) *fakeConn {
+	f := &fakeConn{}
+	r := bufio.NewReader(strings.NewReader(s))
+	f.w = bufio.NewWriter(&f.buf)
+	f.ReadWriter = bufio.NewReadWriter(r, f.w)
+	return f
+}
+
+func (f *fakeConn) Close() error {
+	return nil
+}
+
+func (f *fakeConn) LocalAddr() net.Addr {
+	return nil
+}
+
+func (f *fakeConn) RemoteAddr() net.Addr {
+	return nil
+}
+
+func (f *fakeConn) SetDeadline(time.Time) error {
+	return nil
+}
+
+func (f *fakeConn) SetReadDeadline(time.Time) error {
+	return nil
+}
+
+func (f *fakeConn) SetWriteDeadline(time.Time) error {
+	return nil
+}
+
+func (f *fakeConn) Written() []byte {
+	f.w.Flush()
+	return f.buf.Bytes()
+}
+
+// basic test of net-unicode validation
+func TestNetUnicode(t *testing.T) {
+	if !managesieve.IsNetUnicode("abc\u00a9") {
+		t.Fatalf("expected valid net-unicode")
+	}
+	if managesieve.IsNetUnicode("a\tbc") {
+		t.Fatalf("expected invalid net-unicode")
+	}
+	if managesieve.IsNetUnicode("a\u0080bc") {
+		t.Fatalf("expected invalid net-unicode")
+	}
+	if managesieve.IsNetUnicode("a\u2028bc") {
+		t.Fatalf("expected invalid net-unicode")
+	}
+}
+
+var validScript string = `redirect "111@example.net";
+
+if size :under 10k {
+    redirect "mobile@cell.example.com";
+}
+
+if envelope :contains "to" "tmartin+lists" {
+    redirect "lists@groups.example.com";
+}
+`
+
+// basic functionality
+var basicServer string = `"IMPlemENTATION" "Example1 ManageSieved v001"
+"SASl" "PLAIN DIGEST-MD5 GSSAPI"
+"SIeVE" "fileinto vacation"
+"StaRTTLS"
+"NOTIFY" "xmpp mailto"
+"MAXREdIRECTS" "5"
+"VERSION" "1.0"
+OK
+OK
+"IMPlemENTATION" "Example1 ManageSieved v001"
+"SASl" "PLAIN DIGEST-MD5 GSSAPI"
+"SIeVE" "fileinto vacation"
+"StaRTTLS"
+"NOTIFY" "xmpp mailto"
+"MAXREdIRECTS" "5"
+"VERSION" "1.0"
+OK
+OK (WARNINGS) "line 8: server redirect action limit is 2, this redirect might be ignored"
+OK
+OK (WARNINGS) "line 8: server redirect action limit is 2, this redirect might be ignored"
+OK
+"default" ACTIVE
+OK
+` + quoteServerString(validScript) + `
+OK
+OK
+OK
+OK
+OK
+OK
+`
+
+var basicExpectedOutput string = fixClientOutput(`AUTHENTICATE "PLAIN" "AGZvbwBTM2NSM1Q="
+CAPABILITY
+CHECKSCRIPT @quotedScript@
+HAVESPACE {7+}
+default @scriptLength@
+PUTSCRIPT {7+}
+default @quotedScript@
+SETACTIVE {7+}
+default
+LISTSCRIPTS
+GETSCRIPT {7+}
+default
+SETACTIVE {0+}
+
+RENAMESCRIPT {7+}
+default {4+}
+test
+DELETESCRIPT {4+}
+test
+NOOP
+LOGOUT
+`,
+	"@quotedScript@", quoteClientString(validScript),
+	"@scriptLength@", fmt.Sprint(len(validScript)))
+
+func TestBasicFunctionality(t *testing.T) {
+	conn := newFakeConn(basicServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	if !c.SupportsRFC5804() {
+		t.Fatal("RFC5804 support unexpectedly not detected")
+	}
+
+	if !c.SupportsTLS() {
+		t.Fatal("STARTTLS support unexpectedly not detected")
+	}
+
+	if ext := c.Extensions(); strings.Join(ext, " ") !=
+		"fileinto vacation" {
+		t.Fatalf("expected extensions: [fileinto vacation], got: %v",
+			ext)
+	}
+
+	if notify := c.NotifyMethods(); strings.Join(notify, " ") !=
+		"xmpp mailto" {
+		t.Fatalf("expected notify methods: [xmpp mailto], got: %v",
+			notify)
+	}
+
+	if redir := c.MaxRedirects(); redir != 5 {
+		t.Fatalf("expected max redirects: 5 got: %d", redir)
+	}
+
+	if strings.Join(c.SASLMechanisms(), " ") != "PLAIN DIGEST-MD5 GSSAPI" {
+		t.Fatal("failed check SASL methods")
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	if err := c.Authenticate(auth); err != nil {
+		t.Fatalf("plain authentication failed: %s", err)
+	}
+
+	if err = c.CheckScript(validScript); err != nil {
+		t.Fatalf("CHECKSCRIPT failed: %s", err)
+	}
+
+	if ok, err := c.HaveSpace("default", int64(len(validScript))); err != nil {
+		t.Fatalf("HAVESPACE failed: %s", err)
+	} else if !ok {
+		t.Fatal("HaveSpace unexpectedly returned false")
+	}
+
+	if err = c.PutScript("default", validScript); err != nil {
+		t.Fatalf("PUTSCRIPT failed: %s", err)
+	}
+
+	if err = c.ActivateScript("default"); err != nil {
+		t.Fatalf("SETACTIVE failed: %s", err)
+	}
+
+	if scripts, active, err := c.ListScripts(); err != nil {
+		t.Fatalf("LISTSCRIPTS failed: %s", err)
+	} else if active != "default" {
+		t.Fatalf("failed to get active script")
+	} else if len(scripts) != 1 || scripts[0] != "default" {
+		t.Fatal("failed to get scripts")
+	}
+
+	if recievedScript, err := c.GetScript("default"); err != nil {
+		t.Fatalf("GETSCRIPT failed: %s", err)
+	} else if recievedScript != validScript {
+		t.Fatalf("GETSCRIPT expected:\n%s\ngot:\n%s\n",
+			validScript, recievedScript)
+	}
+
+	if err = c.ActivateScript(""); err != nil {
+		t.Fatalf("SETACTIVE failed: %s", err)
+	}
+
+	if err = c.RenameScript("default", "test"); err != nil {
+		t.Fatalf("RENAMESCRIPT failed: %s", err)
+	}
+
+	if err = c.DeleteScript("test"); err != nil {
+		t.Fatalf("DELETESCRIPT failed: %s", err)
+	}
+
+	if err = c.Noop(); err != nil {
+		t.Fatalf("NOOP failed unexpectedly: %s", err)
+	}
+
+	if err = c.Logout(); err != nil {
+		t.Fatalf("failed to log out: %s", err)
+	}
+
+	clientOutput := string(conn.Written())
+	if clientOutput != basicExpectedOutput {
+		t.Fatalf("expected:\n%s\ngot:\n%s\n", basicExpectedOutput,
+			clientOutput)
+	}
+}
+
+// unexpected EOF after length in literal string
+func TestUnexpectedEOFInLiteral(t *testing.T) {
+	conn := newFakeConn(`"IMPLEMENTATION" {10}`)
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if err != io.ErrUnexpectedEOF {
+		t.Fatalf("expected io.ErrUnexpectedEOF, got %T (%q)", err, err)
+	}
+}
+
+// CR without LF after length in literal string
+func TestInvalidCRInLiteral(t *testing.T) {
+	conn := newFakeConn("{2}\rXX\nOK\n")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// overlong literal string
+func TestOverlongLiteral(t *testing.T) {
+	conn := newFakeConn(fmt.Sprintf("{%d}\r\n", managesieve.ReadLimit+1))
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// string in place of an response code atom
+func TestInvalidResponseCode(t *testing.T) {
+	conn := newFakeConn("NO (\"XXX\")\r\n")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// EOF after response code
+func TestResponseUnexpectedEOF(t *testing.T) {
+	conn := newFakeConn("NO (XXX")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if err != io.ErrUnexpectedEOF {
+		t.Fatalf("expected io.ErrUnexpectedEOF, got %T (%q)", err, err)
+	}
+}
+
+// invalid atom in place of the response message string
+func TestInvalidResponseMessage(t *testing.T) {
+	conn := newFakeConn("BYE XXX\r\n")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// trailing invalid atom after response message
+func TestResponseTrailingData(t *testing.T) {
+	conn := newFakeConn("BYE \"XXX\" XXX\r\n")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// capabilities with atom instead of key string
+func TestCapabilitiesInvalidKey(t *testing.T) {
+	conn := newFakeConn("IMPLEMENTATION \"Example\"\r\nOK\r\n")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// capabilities with atom instead of value string
+func TestCapabilitiesInvalidValue(t *testing.T) {
+	conn := newFakeConn("\"IMPLEMENTATION\" Example\r\nOK\r\n")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+var unexpectedResponseServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+`
+
+// unexpected EOF
+func TestUnexpectedEOF(t *testing.T) {
+	conn := newFakeConn(unexpectedResponseServer)
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if err != io.ErrUnexpectedEOF {
+		t.Fatalf("expected io.ErrUnexpectedEOF, got %T (%q)", err, err)
+	}
+}
+
+// unexpected NO
+func TestUnexpectedNo(t *testing.T) {
+	conn := newFakeConn(unexpectedResponseServer + "NO\n")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(*managesieve.ServerError); !ok {
+		t.Fatalf("expected *managesieve.ServerError, got %T (%q)", err,
+			err)
+	}
+}
+
+// unexpected BYE
+func TestUnexpectedBye(t *testing.T) {
+	conn := newFakeConn(unexpectedResponseServer + "BYE\n")
+	_, err := managesieve.NewClient(conn, "imap.example.net")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(*managesieve.ConnClosedError); !ok {
+		t.Fatalf("expected *managesieve.ConnClosedError, got %T (%q)",
+			err, err)
+	}
+}
+
+var invalidMaxRedirectsServer = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"MAxREDIRECTS" "-1"
+"VERSION" "1.0"
+OK
+`
+
+func TestInvalidMaxRedirects(t *testing.T) {
+	conn := newFakeConn(invalidMaxRedirectsServer)
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	if redir := c.MaxRedirects(); redir != 0 {
+		t.Fatalf("invalid MAXREDIRECTS, expected 0, got %d", redir)
+	}
+}
+
+var minimalServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+`
+
+var haveSpaceServer string = minimalServer + `NO
+NO (QUOTA)
+NO (QUOTA/MAXSIZE) "Quota exceeded"
+`
+
+// handling of unknown errors and quota exceeded errors
+func TestHaveSpace(t *testing.T) {
+	conn := newFakeConn(haveSpaceServer)
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	var ok bool
+	// unknown error response
+	ok, err = c.HaveSpace("foo", 999999)
+	if ok {
+		t.Fatalf("expected failure due to unknown error response to HAVESPACE but succeeded")
+	}
+	if err == nil {
+		t.Fatalf("expected error due to unknown error response to HAVESPACE")
+	}
+	if _, ok = err.(*managesieve.ServerError); !ok {
+		t.Fatalf("expected *managesieve.ServerError, got %T (%q)", err,
+			err)
+	}
+
+	// invalid script size
+	_, err = c.HaveSpace("foo", -999999)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok = err.(managesieve.ProtocolError); !ok {
+		t.Fatalf("expected managesieve.ProtocolError, got %T (%q)", err,
+			err)
+	}
+
+	// quota exceeded
+	ok, err = c.HaveSpace("foo", 999999)
+	if ok {
+		t.Fatalf("expected script to exceed quota")
+	}
+	if err != nil {
+		t.Fatalf("failed to determine whether script exceeds quota: %s",
+			err)
+	}
+}
+
+// handling of errors in response to PUTSCRIPT commands
+func TestPutScript(t *testing.T) {
+	conn := newFakeConn(minimalServer + "BYE\n")
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	// invalid script name
+	err = c.PutScript("def\u2028ault", validScript)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ProtocolError); !ok {
+		t.Fatalf("expected managesieve.ProtocolError, got %T (%q)", err,
+			err)
+	}
+
+	// EOF during upload
+	err = c.PutScript("default", validScript)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(*managesieve.ConnClosedError); !ok {
+		t.Fatalf("expected *managesieve.ConnClosedError, got %T (%q)",
+			err, err)
+	}
+}
+
+// handling of a literal string with an invalid length
+func TestInvalidScriptLength(t *testing.T) {
+	conn := newFakeConn(minimalServer + "{-1}\r\n")
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	_, err = c.GetScript("default")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// missing script in GETSCRIPT response
+func TestEmptyGetScript(t *testing.T) {
+	conn := newFakeConn(minimalServer + "OK\n")
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	_, err = c.GetScript("default")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// invalid script list item which is not a string
+func TestListScriptsNonString(t *testing.T) {
+	conn := newFakeConn(minimalServer + "XXX\nOK\n")
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	_, _, err = c.ListScripts()
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// script list item with an invalid atom
+func TestListScriptsInvalidAtom(t *testing.T) {
+	conn := newFakeConn(minimalServer + "\"default\" XXX\nOK\n")
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	_, _, err = c.ListScripts()
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// script list with multiple active scripts
+var multipleActiveServer string = minimalServer + `"default" ACTIVE
+"alternative" ACTIVE
+"foo"
+OK
+`
+
+func TestListScriptsMultipleActive(t *testing.T) {
+	conn := newFakeConn(multipleActiveServer)
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	_, active, err := c.ListScripts()
+	if err != nil {
+		t.Fatalf("failed to get list of scripts: %s", err)
+	}
+	// although not allowed, the last script marked as active will be
+	// returned
+	if active != "alternative" {
+		t.Fatalf("expected active script \"alternative\", got \"%s\"",
+			active)
+	}
+}
+
+// script list with an empty line
+var listScriptsEmptyLineServer string = minimalServer + `"default" ACTIVE
+"alternative"
+
+OK
+`
+
+func TestListScriptsEmptyLine(t *testing.T) {
+	conn := newFakeConn(listScriptsEmptyLineServer)
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	_, _, err = c.ListScripts()
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// script list item with trailing string
+func TestListScriptsTrailingData(t *testing.T) {
+	conn := newFakeConn(minimalServer +
+		"\"default\" ACTIVE \"alternative\"\nOK\n")
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	_, _, err = c.ListScripts()
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err,
+			err)
+	}
+}
+
+// renaming to an invalid name
+func TestRenameInvalidName(t *testing.T) {
+	conn := newFakeConn(minimalServer + "NO\n")
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	err = c.RenameScript("default", "a\u2028bc")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ProtocolError); !ok {
+		t.Fatalf("expected managesieve.ProtocolError, got %T (%q)",
+			err, err)
+	}
+}
+
+// legacy server which does not implement RFC 5804
+var legacyServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+OK
+NO
+NO
+NO
+`
+
+func TestLegacyServer(t *testing.T) {
+	conn := newFakeConn(legacyServer)
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	err = c.Noop()
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.NotSupportedError); !ok {
+		t.Fatalf("expected managesieve.NotSupportedError, got %T (%q)",
+			err, err)
+	}
+
+	err = c.RenameScript("default", "alternative")
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.NotSupportedError); !ok {
+		t.Fatalf("expected managesieve.NotSupportedError, got %T (%q)",
+			err, err)
+	}
+
+	err = c.CheckScript(validScript)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.NotSupportedError); !ok {
+		t.Fatalf("expected managesieve.NotSupportedError, got %T (%q)",
+			err, err)
+	}
+}
+
+// PLAIN authentication without TLS on non-localhost server
+func TestPlainAuthNoTLS(t *testing.T) {
+	conn := newFakeConn(minimalServer)
+	c, err := managesieve.NewClient(conn, "imap.example.net")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "imap.example.net")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("error expected due to SASL PLAIN authentication without a TLS connection")
+	}
+	if err != managesieve.ErrPlainAuthTLSRequired {
+		t.Fatalf("expected managesieve.ErrPlainAuthTLSRequired, got %T (%q)", err, err)
+	}
+}
+
+// mismatch between actual and expected hostname
+func TestPlainAuthHostnameMismatch(t *testing.T) {
+	conn := newFakeConn(minimalServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "imap.example.net")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(*managesieve.HostNameVerificationError); !ok {
+		t.Fatalf("expected *managesieve.HostNameVerificationError, got %T (%q)", err, err)
+	}
+}
+
+// authentication failure due to invalid credentials
+func TestPlainAuthDenied(t *testing.T) {
+	conn := newFakeConn(minimalServer +
+		"NO \"invalid username or password\"\n")
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	if err = c.Authenticate(auth); err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.AuthenticationError); !ok {
+		t.Fatalf("expected managesieve.AuthenticationError, got %T (%q)", err, err)
+	}
+}
+
+// missing SASL PLAIN authentication method
+var plainAuthMissingServer string = `"IMPLEMENTATION" "Example"
+"SASL" "TEST"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+`
+
+func TestPlainMissingDenied(t *testing.T) {
+	conn := newFakeConn(plainAuthMissingServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but authentication succeeded")
+	}
+	if err != managesieve.ErrPlainAuthNotSupported {
+		t.Fatalf("expected managesieve.ErrPlainAuthNotSupported, got %T (%q)", err, err)
+	}
+}
+
+// custom SASL authentication method handling with a challenge-response exchange
+var customAuthServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN TEST"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+"cXV1eCxnYXJwbHk="
+"d2FsZG8sZnJlZA=="
+OK (SASL "eHl6enkgdGh1ZA==")
+"IMPLEMENTATION" "Example"
+"SASL" "PLAIN TEST"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+`
+
+var (
+	ErrTestAuthNotSupported = errors.New("the server does not support TEST authentication")
+	ErrTestAuthFailed       = errors.New("client authentication failed")
+)
+
+type testAuth struct {
+	WantError bool
+	i         int
+}
+
+func (a *testAuth) Start(server *managesieve.ServerInfo) (string, []byte, error) {
+	if !server.HaveAuth("TEST") {
+		return "TEST", nil, ErrTestAuthNotSupported
+	}
+
+	return "TEST", []byte("baz,qux"), nil
+}
+
+func (a *testAuth) Next(challenge []byte, more bool) ([]byte, error) {
+	if a.i == 0 && a.WantError {
+		return nil, ErrTestAuthFailed
+	}
+
+	var resp []byte
+	if !more {
+		resp = []byte("plugh,xyzzy")
+	} else {
+		a.i++
+		resp = []byte(fmt.Sprintf("qux=%d", a.i))
+	}
+	return resp, nil
+}
+
+// authentication using a custom authentication method
+func TestCustomAuth(t *testing.T) {
+	conn := newFakeConn(customAuthServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := &testAuth{}
+	if err = c.Authenticate(auth); err != nil {
+		t.Fatalf("failed to authenticate using custom authentication method: %s", err)
+	}
+}
+
+// requesting a non-existent SASL authentication method
+func TestNonexistentAuth(t *testing.T) {
+	conn := newFakeConn(minimalServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := &testAuth{}
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if err != ErrTestAuthNotSupported {
+		t.Fatalf("expected ErrAuthMethodNotSupported, got %T (%q)",
+			err, err)
+	}
+}
+
+// SASL authentication method aborted by the client
+var abortAuthServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN TEST"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+"cXV1eCxnYXJwbHk="
+NO "aborted by client"
+`
+
+// handle error raised by client-side custom authentication handler during
+// challenge-response exchange and abort authentication
+func TestAbortAuth(t *testing.T) {
+	conn := newFakeConn(abortAuthServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := &testAuth{WantError: true}
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.AuthenticationError); !ok {
+		t.Fatalf("expected managesieve.AuthenticationError, got %T (%q)", err, err)
+	}
+}
+
+// custom SASL authentication method handling with a challenge which is not a
+// bas64-encoded string
+var corruptAuthServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN TEST"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+"*****"
+`
+
+func TestCustomAuthCorrupt(t *testing.T) {
+	conn := newFakeConn(corruptAuthServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := &testAuth{}
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err)
+	}
+}
+
+// response to aborted SASL authentication is not NO
+var invalidAuthAbortServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN TEST"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+"cXV1eCxnYXJwbHk="
+OK
+`
+
+func TestInvalidAuthAbort(t *testing.T) {
+	conn := newFakeConn(invalidAuthAbortServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := &testAuth{WantError: true}
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ProtocolError); !ok {
+		t.Fatalf("expected managesieve.ProtocolError, got %T (%q)", err, err)
+	}
+}
+
+// invalid response to SASL authentication attempt
+var invalidAuthServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+XXX
+`
+
+func TestInvalidAuthResponse(t *testing.T) {
+	conn := newFakeConn(invalidAuthServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err)
+	}
+}
+
+// invalid trailing argument after first SASL response code argument
+var trailingSASLResponseArgServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+OK (SASL "Zm9v" XXX)
+`
+
+func TestTrailingSASLResponseArg(t *testing.T) {
+	conn := newFakeConn(trailingSASLResponseArgServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err)
+	}
+}
+
+// trailing argument after first SASL response code argument
+var trailingSASLResonseArgServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+OK (SASL "Zm9v" "bar")
+`
+
+func TestTrailingSASLResonseArg(t *testing.T) {
+	conn := newFakeConn(trailingSASLResonseArgServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err)
+	}
+}
+
+// SASL response code argument is not base64-encoded
+var invalidSASLResponseArgServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+OK (SASL "*****")
+`
+
+func TestInvalidSASLResponseArg(t *testing.T) {
+	conn := newFakeConn(invalidSASLResponseArgServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.ParserError); !ok {
+		t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err)
+	}
+}
+
+// argument passed with SASL response code rejected by client authentication
+// handler
+var saslResponseRejectedServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN TEST"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+OK (SASL "Zm9v")
+`
+
+func TestSASLResponseRejected(t *testing.T) {
+	conn := newFakeConn(saslResponseRejectedServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := &testAuth{WantError: true}
+	if err = c.Authenticate(auth); err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(managesieve.AuthenticationError); !ok {
+		t.Fatalf("expected managesieve.AuthenticationError, got %T (%q)", err, err)
+	}
+}
+
+// CAPABILITIES command after authentication failed
+var authCapabilitiesFailedServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+OK
+NO
+`
+
+func TestAuthCapabilitiesFailed(t *testing.T) {
+	conn := newFakeConn(authCapabilitiesFailedServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(*managesieve.ServerError); !ok {
+		t.Fatalf("expected *managesieve.ServerError, got %T (%q)", err, err)
+	}
+}
+
+// BYE in response to SASL authentication attempt
+var authByeServer string = `"IMPLEMENTATION" "Example"
+"SASL" "PLAIN"
+"SIEVE" "fileinto vacation"
+"StARTTLS"
+"VERSION" "1.0"
+OK
+BYE "authentication denied"
+`
+
+func TestAuthBye(t *testing.T) {
+	conn := newFakeConn(authByeServer)
+	c, err := managesieve.NewClient(conn, "localhost")
+	if err != nil {
+		t.Fatalf("failed to create client: %s", err)
+	}
+
+	auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost")
+	err = c.Authenticate(auth)
+	if err == nil {
+		t.Fatalf("expected error but succeeded")
+	}
+	if _, ok := err.(*managesieve.ConnClosedError); !ok {
+		t.Fatalf("expected *managesieve.ConnClosedError, got %T (%q)", err, err)
+	}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/parser.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,197 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 managesieve
+
+import (
+	"strings"
+)
+
+type response int
+
+const (
+	responseInvalid response = iota
+	responseOk
+	responseNo
+	responseBye
+)
+
+func lookupResponse(s string) response {
+	switch s {
+	case "OK":
+		return responseOk
+	case "NO":
+		return responseNo
+	case "BYE":
+		return responseBye
+	}
+	return responseInvalid
+}
+
+type reply struct {
+	lines    [][]*token
+	resp     response
+	code     string
+	codeArgs []string
+	msg      string
+}
+
+func parseCapabilities(r *reply) (map[string]string, error) {
+	capa := make(map[string]string)
+	for _, tokens := range r.lines {
+		var k, v string
+		if tokens[0].typ != tokenQuotedString &&
+			tokens[0].typ != tokenLiteralString {
+			return nil, ParserError("failed to parse capability name: expected string")
+		}
+		k = strings.ToUpper(tokens[0].literal)
+
+		if len(tokens) > 1 {
+			if tokens[1].typ != tokenQuotedString &&
+				tokens[1].typ != tokenLiteralString {
+				return nil, ParserError("failed to parse capability value: expected string")
+			}
+			v = tokens[1].literal
+		}
+		capa[k] = v
+	}
+	return capa, nil
+}
+
+type parser struct {
+	s *scanner
+}
+
+func (p *parser) isResponseLine(tokens []*token) bool {
+	return tokens[0].typ == tokenAtom &&
+		lookupResponse(tokens[0].literal) != responseInvalid
+}
+
+func (p *parser) parseResponseLine(tokens []*token) (*reply, error) {
+	var i int
+	next := func() (*token, bool) {
+		if i >= len(tokens) {
+			return nil, false
+		}
+		tok := tokens[i]
+		i++
+		return tok, true
+	}
+
+	// response
+	tok, cont := next()
+	r := &reply{resp: lookupResponse(tok.literal)}
+
+	// code starts with left parenthesis
+	tok, cont = next()
+	if !cont {
+		// only response without code and/or message
+		return r, nil
+	}
+	if tok.typ == tokenLeftParenthesis {
+		// code atom
+		tok, cont = next()
+		if !cont || tok.typ != tokenAtom {
+			return nil, ParserError("failed to parse response code: expected atom")
+		}
+		r.code = tok.literal
+
+		// followed by zero or more string arguments
+		for {
+			tok, cont = next()
+			if !cont {
+				return nil, ParserError("failed to parse response code: unexpected end of line")
+			}
+			if tok.typ != tokenQuotedString &&
+				tok.typ != tokenLiteralString {
+				break
+			}
+			r.codeArgs = append(r.codeArgs, tok.literal)
+		}
+
+		// terminated by a right parenthesis
+		if tok.typ != tokenRightParenthesis {
+			return nil, ParserError("failed to parse response code: expected right parenthesis")
+		}
+
+		tok, cont = next()
+		if !cont {
+			// response with code but no message
+			return r, nil
+		}
+	}
+
+	// message string
+	if tok.typ != tokenQuotedString &&
+		tok.typ != tokenLiteralString {
+		return nil, ParserError("failed to parse response message: expected string")
+	}
+	r.msg = strings.TrimSpace(tok.literal)
+
+	// end of line
+	if _, cont = next(); cont {
+		return nil, ParserError("failed to parse response line: unexpected trailing data")
+	}
+
+	return r, nil
+}
+
+func (p *parser) readLine() ([]*token, error) {
+	tokens := make([]*token, 0)
+	for {
+		tok, err := p.s.scan()
+		if err != nil {
+			return nil, err
+		}
+		if tok.typ == tokenCRLF {
+			break
+		}
+		tokens = append(tokens, tok)
+	}
+
+	return tokens, nil
+}
+
+func (p *parser) readReply() (*reply, error) {
+	var r *reply
+	var lines [][]*token = make([][]*token, 0, 1)
+	for {
+		tokens, err := p.readLine()
+		if err != nil {
+			return nil, err
+		}
+		if len(tokens) == 0 {
+			return nil, ParserError("unexpected empty line")
+		}
+		// check for response tokens
+		if p.isResponseLine(tokens) {
+			r, err = p.parseResponseLine(tokens)
+			if err != nil {
+				return nil, err
+			}
+			r.lines = lines
+			break
+		}
+		lines = append(lines, tokens)
+	}
+
+	return r, nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scanner.go	Thu Oct 15 09:11:05 2020 +0200
@@ -0,0 +1,276 @@
+// Copyright (C) 2020 Guido Berhoerster <guido+managesieve@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 managesieve
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+	"unicode"
+)
+
+const ReadLimit = 1 * 1024 * 1024 // 1 MiB
+
+type tokenType int
+
+const (
+	tokenInvalid tokenType = iota
+	tokenCRLF
+	tokenLeftParenthesis
+	tokenRightParenthesis
+	tokenAtom
+	tokenQuotedString
+	tokenLiteralString
+)
+
+type token struct {
+	typ     tokenType
+	literal string
+}
+
+func (t token) String() string {
+	switch t.typ {
+	case tokenInvalid:
+		return "Invalid"
+	case tokenLeftParenthesis:
+		return "LeftParenthesis: " + t.literal
+	case tokenRightParenthesis:
+		return "RightParenthesis: " + t.literal
+	case tokenAtom:
+		return "Atom: " + t.literal
+	case tokenCRLF:
+		return fmt.Sprintf("CRLF: %q", t.literal)
+	case tokenQuotedString:
+		return fmt.Sprintf("QuotedString: %q", t.literal)
+	case tokenLiteralString:
+		return fmt.Sprintf("LiteralString: %q", t.literal)
+	}
+	return fmt.Sprintf("unknown token: %q", t.literal)
+}
+
+type scanner struct {
+	lr *io.LimitedReader // do not read from this, only for access to N
+	br *bufio.Reader     // wraps LimitReader
+}
+
+func newScanner(r io.Reader) *scanner {
+	lr := &io.LimitedReader{R: r, N: ReadLimit}
+	br := bufio.NewReader(lr)
+	return &scanner{lr, br}
+}
+
+func (s *scanner) scanCRLF() (*token, error) {
+	c, _, err := s.br.ReadRune()
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+	literal := string(c)
+	// accept LF without CR
+	if c == '\r' {
+		c, _, err = s.br.ReadRune()
+		if err != nil {
+			if err == io.EOF {
+				err = io.ErrUnexpectedEOF
+			}
+			return nil, err
+		}
+		literal += string(c)
+	}
+	if c != '\n' {
+		return nil, ParserError(fmt.Sprintf(`expected '\n', got %q`, c))
+	}
+	return &token{typ: tokenCRLF, literal: literal}, nil
+}
+
+func (s *scanner) scanParenthesis() (*token, error) {
+	c, _, err := s.br.ReadRune()
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+	var typ tokenType
+	if c == '(' {
+		typ = tokenLeftParenthesis
+	} else if c == ')' {
+		typ = tokenRightParenthesis
+	} else {
+		return nil,
+			ParserError(fmt.Sprintf("expected parenthesis, got %q",
+				c))
+	}
+	return &token{typ: typ, literal: string(c)}, nil
+}
+
+func isAtomRune(c rune) bool {
+	return c == '!' ||
+		(c >= 0x23 && c <= 0x27) ||
+		(c >= 0x2a && c <= 0x5b) ||
+		(c >= 0x5d && c <= 0x7a) ||
+		(c >= 0x7c && c <= 0x7e)
+}
+
+func (s *scanner) scanAtom() (*token, error) {
+	var sb strings.Builder
+	var c rune
+	for {
+		c, _, err := s.br.ReadRune()
+		if err != nil {
+			if err == io.EOF {
+				err = io.ErrUnexpectedEOF
+			}
+			return nil, err
+		}
+		if isAtomRune(c) {
+			sb.WriteRune(unicode.ToUpper(c))
+		} else {
+			s.br.UnreadRune()
+			break
+		}
+	}
+	if sb.Len() == 0 {
+		return nil, ParserError(fmt.Sprintf("expected atom, got %q", c))
+	}
+	return &token{typ: tokenAtom, literal: sb.String()}, nil
+}
+
+func (s *scanner) scanQuotedString() (*token, error) {
+	c, _, err := s.br.ReadRune()
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+	if c != '"' {
+		return nil, ParserError(fmt.Sprintf("expected '\"', got %q", c))
+	}
+	qs, err := s.br.ReadString('"')
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+	return &token{typ: tokenQuotedString, literal: qs[:len(qs)-1]},
+		nil
+}
+
+func (s *scanner) scanLiteralString() (*token, error) {
+	c, _, err := s.br.ReadRune()
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+	if c != '{' {
+		return nil, ParserError(fmt.Sprintf("expected '{', got %q", c))
+	}
+	nstr, err := s.br.ReadString('}')
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+	n, err := strconv.ParseUint(nstr[:len(nstr)-1], 10, 32)
+	if err != nil {
+		return nil, ParserError("failed to parse literal string length: " + err.Error())
+	}
+	if n > uint64(s.lr.N) {
+		return nil, ParserError(fmt.Sprintf("string too long: %d", n))
+	}
+
+	if _, err := s.scanCRLF(); err != nil {
+		return nil, err
+	}
+
+	b := make([]byte, n)
+	_, err = io.ReadFull(s.br, b)
+	ls := string(b)
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+
+	return &token{typ: tokenLiteralString, literal: ls}, nil
+}
+
+func (s *scanner) skipSpace() error {
+	for {
+		b, err := s.br.ReadByte()
+		if err != nil {
+			if err == io.EOF {
+				err = io.ErrUnexpectedEOF
+			}
+			return err
+		}
+
+		if b != ' ' {
+			s.br.UnreadByte()
+			break
+		}
+	}
+
+	return nil
+}
+
+func (s *scanner) scan() (*token, error) {
+	if err := s.skipSpace(); err != nil {
+		return nil, err
+	}
+
+	buf, err := s.br.Peek(1)
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+	b := buf[0]
+	switch {
+	case b == '\r':
+		fallthrough
+	case b == '\n':
+		return s.scanCRLF()
+	case b == '"':
+		return s.scanQuotedString()
+	case b == '{':
+		return s.scanLiteralString()
+	case b == '(':
+		fallthrough
+	case b == ')':
+		return s.scanParenthesis()
+	case isAtomRune(rune(b)):
+		return s.scanAtom()
+	}
+	return nil, ParserError(fmt.Sprintf("invalid character: %q", b))
+}