diff managesieve.go @ 0:6369453d47a3

Initial revision
author Guido Berhoerster <guido+managesieve@berhoerster.name>
date Thu, 15 Oct 2020 09:11:05 +0200
parents
children 8413916df2be
line wrap: on
line diff
--- /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
+}