summaryrefslogtreecommitdiff
path: root/sievelib/managesieve.py
diff options
context:
space:
mode:
Diffstat (limited to 'sievelib/managesieve.py')
-rw-r--r--sievelib/managesieve.py639
1 files changed, 639 insertions, 0 deletions
diff --git a/sievelib/managesieve.py b/sievelib/managesieve.py
new file mode 100644
index 0000000..8095abc
--- /dev/null
+++ b/sievelib/managesieve.py
@@ -0,0 +1,639 @@
+#!/usr/bin/env python
+# coding: utf-8
+
+"""
+A MANAGESIEVE client
+
+A protocol for securely managing Sieve scripts on a remote server.
+This protocol allows a user to have multiple scripts, and also alerts
+a user to syntactically flawed scripts.
+
+Implementation based on <draft-martin-managesieve-12>.
+"""
+
+import sys, os
+import socket
+import re
+import base64
+import ssl
+
+from digest_md5 import DigestMD5
+
+CRLF = '\r\n'
+
+KNOWN_CAPABILITIES = ["IMPLEMENTATION", "SASL", "SIEVE",
+ "STARTTLS", "NOTIFY", "LANGUAGE",
+ "RENAME"]
+
+SUPPORTED_AUTH_MECHS = ["DIGEST-MD5", "PLAIN", "LOGIN"]
+
+class Error(Exception):
+ pass
+
+class Response(Exception):
+ def __init__(self, code, data):
+ self.code = code
+ self.data = data
+
+ def __str__(self):
+ return "%s %s" % (self.code, self.data)
+
+class Literal(Exception):
+ def __init__(self, value):
+ self.value = value
+
+ def __str__(self):
+ return "{%d}" % self.value
+
+def authentication_required(meth):
+ """Simple class method decorator.
+
+ Checks if the client is currently connected.
+
+ :param meth: the original called method
+ """
+ def check(cls, *args, **kwargs):
+ if cls.authenticated:
+ return meth(cls, *args, **kwargs)
+ raise Error("Authentication required")
+ return check
+
+class Client(object):
+ read_size = 4096
+ read_timeout = 5
+
+ def __init__(self, srvaddr, srvport=2000, debug=False):
+ self.srvaddr = srvaddr
+ self.srvport = srvport
+ self.__debug = debug
+ self.sock = None
+ self.__read_buffer = ""
+ self.authenticated = False
+ self.errcode = None
+
+ self.__capabilities = {}
+ self.__respcode_expr = re.compile("(OK|NO|BYE)\s*(.+)?")
+ self.__error_expr = re.compile('(\(\w+\))?\s*(".+")')
+ self.__size_expr = re.compile("\{(\d+)\+?\}")
+ self.__active_expr = re.compile("ACTIVE", re.IGNORECASE)
+
+ def __del__(self):
+ if self.sock is not None:
+ self.sock.close()
+ self.sock = None
+
+ def __dprint(self, message):
+ if not self.__debug:
+ return
+ print "DEBUG: %s" % message
+
+ def __read_block(self, size):
+ """Read a block of 'size' bytes from the server.
+
+ An internal buffer is used to read data from the server. If
+ enough data is available from it, we return that data.
+
+ Eventually, we try to grab the missing part from the server
+ for Client.read_timeout seconds. If no data can be
+ retrieved, it is considered as a fatal error and an 'Error'
+ exception is raised.
+
+ :param size: number of bytes to read
+ :rtype: string
+ :returns: the read block (can be empty)
+ """
+ buf = ""
+ if len(self.__read_buffer):
+ limit = size if size <= len(self.__read_buffer) else len(self.__read_buffer)
+ buf = self.__read_buffer[:limit]
+ self.__read_buffer = self.__read_buffer[limit:]
+ size -= limit
+ if not size:
+ return buf
+ try:
+ buf += self.sock.recv(size)
+ except (socket.timeout, ssl.SSLError), e:
+ raise Error("Failed to read %d bytes from the server" % size)
+ return buf
+
+ def __read_line(self):
+ """Read one line from the server.
+
+ An internal buffer is used to read data from the server
+ (blocks of Client.read_size bytes). If the buffer
+ is not empty, we try to find an entire line to return.
+
+ If we failed, we try to read new content from the server for
+ Client.read_timeout seconds. If no data can be
+ retrieved, it is considered as a fatal error and an 'Error'
+ exception is raised.
+
+ :rtype: string
+ :return: the read line
+ """
+ ret = ""
+ while True:
+ try:
+ pos = self.__read_buffer.index(CRLF)
+ ret = self.__read_buffer[0:pos]
+ self.__read_buffer = self.__read_buffer[pos+len(CRLF):]
+ break
+ except ValueError:
+ pass
+ try:
+ nval = self.sock.recv(self.read_size)
+ if not len(nval):
+ break
+ self.__read_buffer += nval
+ except (socket.timeout, ssl.SSLError):
+ raise Error("Failed to read data from the server")
+
+ if len(ret):
+ m = self.__size_expr.match(ret)
+ if m:
+ raise Literal(int(m.group(1)))
+
+ m = self.__respcode_expr.match(ret)
+ if m:
+ if m.group(1) == "BYE":
+ raise Error("Connection closed by server")
+ if m.group(1) == "NO":
+ self.__parse_error(m.group(2))
+ raise Response(m.group(1), m.group(2))
+ return ret
+
+ def __read_response(self, nblines=-1):
+ """Read a response from the server.
+
+ In the usual case, we read lines until we find one that looks
+ like a response (OK|NO|BYE\s*(.+)?).
+
+ If *nblines* > 0, we read excactly nblines before returning.
+
+ :param nblines: number of lines to read (default : -1)
+ :rtype: tuple
+ :return: a tuple of the form (code, data, response). If
+ nblines is provided, code and data can be equal to None.
+ """
+ resp, code, data = ("", None, None)
+ cpt = 0
+ while True:
+ try:
+ line = self.__read_line()
+ except Response, inst:
+ code = inst.code
+ data = inst.data
+ break
+ except Literal, inst:
+ resp += self.__read_block(inst.value)
+ continue
+ if not len(line):
+ continue
+ resp += line + CRLF
+ cpt += 1
+ if nblines != -1 and cpt == nblines:
+ break
+
+ return (code, data, resp)
+
+ def __prepare_args(self, args):
+ """Format command arguments before sending them.
+
+ Command arguments of type string must be quoted, the only
+ exception concerns size indication (of the form {\d\+?}).
+
+ :param args: list of arguments
+ :return: a list for transformed arguments
+ """
+ ret = []
+ for a in args:
+ if type(a) in [str, unicode] and self.__size_expr.match(a) is None:
+ ret += ['"%s"' % a.encode('utf-8')]
+ continue
+ ret += ["%s" % str(a)]
+ return ret
+
+ def __send_command(self, name, args=[], withcontent=False, extralines=[], nblines=-1):
+ """Send a command to the server.
+
+ If args is not empty, we concatenate the given command with
+ the content of this list. If extralines is not empty, they are
+ sent one by one to the server. (CLRF are automatically appended to them)
+
+ We wait for a response just after the command has been sent.
+
+ :param name: the command to sent
+ :param args: a list of arguments for this command
+ :param withcontent: tells the function to return the server's response or not
+ :param extralines: a list of extra lines to sent after the command
+ :param nblines: the number of response lines to read (all by default)
+
+ :returns: a tuple of the form (code, data[, response])
+ """
+ tosend = name
+ if len(args):
+ tosend += " " + " ".join(self.__prepare_args(args))
+ self.__dprint("Command: %s" % tosend)
+ self.sock.send("%s%s" % (tosend, CRLF))
+ for l in extralines:
+ self.sock.send("%s%s" % (l, CRLF))
+ code, data, content = self.__read_response(nblines)
+
+ if withcontent:
+ return (code, data, content)
+ return (code, data)
+
+ def __get_capabilities(self):
+ code, data, capabilities = self.__read_response()
+ if code == "NO":
+ return False
+
+ for l in capabilities.splitlines()[0:-1]:
+ parts = l.split(None, 1)
+ cname = parts[0].strip('"')
+ if not cname in KNOWN_CAPABILITIES:
+ continue
+ self.__capabilities[cname] = \
+ parts[1].strip('"') if len(parts) > 1 else None
+ return True
+
+ def __parse_error(self, text):
+ """Parse an error received from the server.
+
+ if text corresponds to a size indication, we grab the
+ remaining content from the server.
+
+ Otherwise, we try to match an error of the form \(\w+\)?\s*".+"
+
+ On succes, the two public members errcode and errmsg are
+ filled with the parsing results.
+
+ :param text: the response to parse
+ """
+ m = self.__size_expr.match(text)
+ if m is not None:
+ self.errcode = ""
+ self.errmsg = self.__read_block(int(m.group(1)) + 2)
+ return
+
+ m = self.__error_expr.match(text)
+ if m is None:
+ raise Error("Bad error message")
+ if m.group(1) is not None:
+ self.errcode = m.group(1).strip("()")
+ else:
+ self.errcode = ""
+ self.errmsg = m.group(2).strip('"')
+
+ def _plain_authentication(self, login, password):
+ """SASL PLAIN authentication
+
+ :param login: username
+ :param password: clear password
+ :return: True on success, False otherwise.
+ """
+ params = base64.b64encode('\0' + '\0'.join([login, password]))
+ code, data = self.__send_command("AUTHENTICATE", ["PLAIN", params])
+ if code == "OK":
+ return True
+ return False
+
+ def _login_authentication(self, login, password):
+ """SASL LOGIN authentication
+
+ :param login: username
+ :param password: clear password
+ :return: True on success, False otherwise.
+ """
+ extralines = ['"%s"' % base64.b64encode(login),
+ '"%s"' % base64.b64encode(password)]
+ code, data = self.__send_command("AUTHENTICATE", ["LOGIN"],
+ extralines=extralines)
+ if code == "OK":
+ return True
+ return False
+
+ def _digest_md5_authentication(self, login, password):
+ """SASL DIGEST-MD5 authentication
+
+ :param login: username
+ :param password: clear password
+ :return: True on success, False otherwise.
+ """
+ code, data, challenge = \
+ self.__send_command("AUTHENTICATE", ["DIGEST-MD5"],
+ withcontent=True, nblines=1)
+ dmd5 = DigestMD5(challenge, "sieve/%s" % self.srvaddr)
+
+ code, data, challenge = \
+ self.__send_command('"%s"' % dmd5.response(login, password),
+ withcontent=True, nblines=1)
+ if not len(challenge):
+ return False
+ if not dmd5.check_last_challenge(login, password, challenge):
+ self.errmsg = "Bad challenge received from server"
+ return False
+ code, data = self.__send_command('""')
+ if code == "OK":
+ return True
+ return False
+
+ def __authenticate(self, login, password, authmech=None):
+ """AUTHENTICATE command
+
+ Actually, it is just a wrapper to the real commands (one by
+ mechanism). We try all supported mechanisms (from the
+ strongest to the weakest) until we find one supported by the
+ server.
+
+ Then we try to authenticate (only once).
+
+ :param login: username
+ :param password: clear password
+ :param authmech: prefered authentication mechanism
+ :return: True on success, False otherwise
+ """
+ if not self.__capabilities.has_key("SASL"):
+ raise Error("SASL not supported by the server")
+ srv_mechanisms = self.get_sasl_mechanisms()
+
+ if authmech is None or authmech not in SUPPORTED_AUTH_MECHS:
+ mech_list = SUPPORTED_AUTH_MECHS
+ else:
+ mech_list = [authmech]
+
+ for mech in mech_list:
+ if not mech in srv_mechanisms:
+ continue
+ mech = mech.lower()
+ mech = re.sub("-", "_", mech)
+ if getattr(self, "_%s_authentication" % mech.lower())(login, password):
+ self.authenticated = True
+ return True
+ return False
+
+ self.errmsg = "No suitable mechanism found"
+ return False
+
+ def __starttls(self, keyfile=None, certfile=None):
+ """STARTTLS command
+
+ See MANAGESIEVE specifications, section 2.2.
+
+ :param keyfile: an eventual private key to use
+ :param certfile: an eventual certificate to use
+ :rtype: boolean
+ """
+ if not self.has_tls_support():
+ raise Error("STARTTLS not supported by the server")
+ code, data = self.__send_command("STARTTLS")
+ if code != "OK":
+ return False
+ try:
+ nsock = ssl.wrap_socket(self.sock, keyfile, certfile)
+ except ssl.SSLError, e:
+ raise Error("SSL error: %s" % str(e))
+ self.sock = nsock
+ self.__capabilities = {}
+ self.__get_capabilities()
+ return True
+
+ def get_implementation(self):
+ """Returns the IMPLEMENTATION value.
+
+ It is read from server capabilities. (see the CAPABILITY
+ command)
+
+ :rtype: string
+ """
+ return self.__capabilities["IMPLEMENTATION"]
+
+ def get_sasl_mechanisms(self):
+ """Returns the supported authentication mechanisms.
+
+ They're read from server capabilities. (see the CAPABILITY
+ command)
+
+ :rtype: list of string
+ """
+ return self.__capabilities["SASL"].split()
+
+ def has_tls_support(self):
+ """Tells if the server has STARTTLS support or not.
+
+ It is read from server capabilities. (see the CAPABILITY
+ command)
+
+ :rtype: boolean
+ """
+ return self.__capabilities.has_key("STARTTLS")
+
+ def get_sieve_capabilities(self):
+ """Returns the SIEVE extensions supported by the server.
+
+ They're read from server capabilities. (see the CAPABILITY
+ command)
+
+ :rtype: string
+ """
+ if type(self.__capabilities["SIEVE"]) == str:
+ self.__capabilities["SIEVE"] = self.__capabilities["SIEVE"].split()
+ return self.__capabilities["SIEVE"]
+
+ def connect(self, login, password, starttls=False, authmech=None):
+ """Establish a connection with the server.
+
+ This function must be used. It read the server capabilities
+ and wraps calls to STARTTLS and AUTHENTICATE commands.
+
+ :param login: username
+ :param password: clear password
+ :param starttls: use a TLS connection or not
+ :param authmech: prefered authenticate mechanism
+ :rtype: boolean
+ """
+ try:
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sock.connect((self.srvaddr, self.srvport))
+ self.sock.settimeout(Client.read_timeout)
+ except socket.error, msg:
+ raise Error("Connection to server failed: %s" % str(msg))
+
+ if not self.__get_capabilities():
+ raise Error("Failed to read capabilities from server")
+ if starttls and not self.__starttls():
+ return False
+ if self.__authenticate(login, password, authmech):
+ return True
+ return False
+
+ def logout(self):
+ """Disconnect from the server
+
+ See MANAGESIEVE specifications, section 2.3
+ """
+ self.__send_command("LOGOUT")
+
+ def capability(self):
+ """Ask server capabilities.
+
+ See MANAGESIEVE specifications, section 2.4 This command does
+ not affect capabilities recorded by this client.
+
+ :rtype: string
+ """
+ code, data, capabilities = \
+ self.__send_command("CAPABILITY", withcontent=True)
+ if code == "OK":
+ return capabilities
+ return None
+
+ @authentication_required
+ def havespace(self, scriptname, scriptsize):
+ """Ask for available space.
+
+ See MANAGESIEVE specifications, section 2.5
+
+ :param scriptname: script's name
+ :param scriptsize: script's size
+ :rtype: boolean
+ """
+ code, data = self.__send_command("HAVESPACE", [scriptname, scriptsize])
+ if code == "OK":
+ return True
+ return False
+
+ @authentication_required
+ def listscripts(self):
+ """List available scripts.
+
+ See MANAGESIEVE specifications, section 2.7
+
+ :returns: a 2-uple (active script, [script1, ...])
+ """
+ code, data, listing = self.__send_command("LISTSCRIPTS", withcontent=True)
+ if code == "NO":
+ return None
+ ret = []
+ active_script = None
+ for l in listing.splitlines():
+ if self.__size_expr.match(l):
+ continue
+ m = re.match('"([^"]+)"\s*(.+)', l)
+ if m is None:
+ ret += [l.strip('"')]
+ else:
+ if self.__active_expr.match(m.group(2)):
+ active_script = m.group(1)
+ else:
+ ret += [m.group(1)]
+ self.__dprint(ret)
+ return (active_script, ret)
+
+ @authentication_required
+ def getscript(self, name):
+ """Download a script from the server
+
+ See MANAGESIEVE specifications, section 2.9
+
+ :param name: script's name
+ :rtype: string
+ :returns: the script's content on succes, None otherwise
+ """
+ code, data, content = self.__send_command("GETSCRIPT", [name], withcontent=True)
+ if code == "OK":
+ lines = content.splitlines()
+ if self.__size_expr.match(lines[0]) is not None:
+ lines = lines[1:]
+ return "\n".join(lines)
+ return None
+
+ @authentication_required
+ def putscript(self, name, content):
+ """Upload a script to the server
+
+ See MANAGESIEVE specifications, section 2.6
+
+ :param name: script's name
+ :param content: script's content
+ :rtype: boolean
+ """
+ if type(content) is unicode:
+ content = content.encode("utf-8")
+
+ content = "{%d+}%s%s" % (len(content), CRLF, content)
+ code, data = \
+ self.__send_command("PUTSCRIPT", [name, content])
+ if code == "OK":
+ return True
+ return False
+
+ @authentication_required
+ def deletescript(self, name):
+ """Delete a script from the server
+
+ See MANAGESIEVE specifications, section 2.10
+
+ :param name: script's name
+ :rtype: boolean
+ """
+ code, data = self.__send_command("DELETESCRIPT", [name])
+ if code == "OK":
+ return True
+ return False
+
+ @authentication_required
+ def renamescript(self, oldname, newname):
+ """Rename a script on the server
+
+ See MANAGESIEVE specifications, section 2.11.1
+
+ As this command is optional, we emulate it if the server does
+ not support it.
+
+ :param oldname: current script's name
+ :param newname: new script's name
+ :rtype: boolean
+ """
+ if self.__capabilities.has_key("RENAMESCRIPT"):
+ code, data = self.__send_command("RENAMESCRIPT", [oldname, newname])
+ if code == "OK":
+ return True
+ return False
+
+ (active_script, scripts) = self.listscripts()
+ if scripts == None or not oldname in scripts:
+ self.errmsg = "Old script does not exist"
+ return False
+ if newname in scripts:
+ self.errmsg = "New script already exists"
+ return False
+ oldscript = self.getscript(oldname)
+ if oldscript is None:
+ return False
+ if not self.putscript(newname, oldscript):
+ return False
+ if active_script == oldname:
+ if not self.setactive(newname):
+ return False
+ if not self.deletescript(oldname):
+ return False
+ return True
+
+ @authentication_required
+ def setactive(self, scriptname):
+ """Define the active script
+
+ See MANAGESIEVE specifications, section 2.8
+
+ If scriptname is empty, the current active script is disabled,
+ ie. there will be no active script anymore.
+
+ :param scriptname: script's name
+ :rtype: boolean
+ """
+ code, data = self.__send_command("SETACTIVE", [scriptname])
+ if code == "OK":
+ return True
+ return False
+