"""Sieve management client. A Protocol for Remotely Managing Sieve Scripts Based on """ __version__ = "0.4.2" __author__ = """Hartmut Goebel Ulrich Eck April 2001 """ import binascii, re, socket, time, random, sys try: import ssl ssl_wrap_socket = ssl.wrap_socket except ImportError: ssl_wrap_socket = socket.ssl __all__ = [ 'MANAGESIEVE', 'SIEVE_PORT', 'OK', 'NO', 'BYE', 'Debug'] Debug = 0 CRLF = '\r\n' SIEVE_PORT = 2000 OK = 'OK' NO = 'NO' BYE = 'BYE' AUTH_PLAIN = "PLAIN" AUTH_LOGIN = "LOGIN" # authentication mechanisms currently supported # in order of preference AUTHMECHS = [AUTH_PLAIN, AUTH_LOGIN] # todo: return results or raise exceptions? # todo: on result 'BYE' quit immediatly # todo: raise exception on 'BYE'? # Commands commands = { # name valid states 'STARTTLS': ('NONAUTH',), 'AUTHENTICATE': ('NONAUTH',), 'LOGOUT': ('NONAUTH', 'AUTH', 'LOGOUT'), 'CAPABILITY': ('NONAUTH', 'AUTH'), 'GETSCRIPT': ('AUTH', ), 'PUTSCRIPT': ('AUTH', ), 'SETACTIVE': ('AUTH', ), 'DELETESCRIPT': ('AUTH', ), 'LISTSCRIPTS': ('AUTH', ), 'HAVESPACE': ('AUTH', ), # bogus command to receive a NO after STARTTLS (see starttls() ) 'BOGUS': ('NONAUTH', 'AUTH', 'LOGOUT'), } ### needed Oknobye = re.compile(r'(?P(OK|NO|BYE))' r'( \((?P.*)\))?' r'( (?P.*))?') # draft-martin-managesieve-04.txt defines the size tag of literals to # contain a '+' (plus sign) behind the digits, but timsieved does not # send one. Thus we are less strikt here: Literal = re.compile(r'.*{(?P\d+)\+?}$') re_dquote = re.compile(r'"(([^"\\]|\\.)*)"') re_esc_quote = re.compile(r'\\([\\"])') class SSLFakeSocket: """A fake socket object that really wraps a SSLObject. It only supports what is needed in managesieve. """ def __init__(self, realsock, sslobj): self.realsock = realsock self.sslobj = sslobj def send(self, str): self.sslobj.write(str) return len(str) sendall = send def close(self): self.realsock.close() class SSLFakeFile: """A fake file like object that really wraps a SSLObject. It only supports what is needed in managesieve. """ def __init__(self, sslobj): self.sslobj = sslobj def readline(self): str = "" chr = None while chr != "\n": chr = self.sslobj.read(1) str += chr return str def read(self, size=0): if size == 0: return '' else: return self.sslobj.read(size) def close(self): pass def sieve_name(name): # todo: correct quoting return '"%s"' % name def sieve_string(string): return '{%d+}%s%s' % ( len(string), CRLF, string ) class MANAGESIEVE: """Sieve client class. Instantiate with: MANAGESIEVE(host [, port]) host - host's name (default: localhost) port - port number (default: standard Sieve port). use_tls - switch to TLS automatically, if server supports keyfile - keyfile to use for TLS (optional) certfile - certfile to use for TLS (optional) All Sieve commands are supported by methods of the same name (in lower-case). Each command returns a tuple: (type, [data, ...]) where 'type' is usually 'OK' or 'NO', and 'data' is either the text from the tagged response, or untagged results from command. All arguments to commands are converted to strings, except for AUTHENTICATE. """ """ However, the 'password' argument to the LOGIN command is always quoted. If you want to avoid having an argument string quoted (eg: the 'flags' argument to STORE) then enclose the string in parentheses (eg: "(\Deleted)"). Errors raise the exception class .error(""). IMAP4 server errors raise .abort(""), which is a sub-class of 'error'. Mailbox status changes from READ-WRITE to READ-ONLY raise the exception class .readonly(""), which is a sub-class of 'abort'. "error" exceptions imply a program error. "abort" exceptions imply the connection should be reset, and the command re-tried. "readonly" exceptions imply the command should be re-tried. Note: to use this module, you must read the RFCs pertaining to the IMAP4 protocol, as the semantics of the arguments to each IMAP4 command are left to the invoker, not to mention the results. """ class error(Exception): """Logical errors - debug required""" class abort(error): """Service errors - close and retry""" def __clear_knowledge(self): """clear/init any knowledge obtained from the server""" self.capabilities = [] self.loginmechs = [] self.implementation = '' self.supports_tls = 0 def __init__(self, host='', port=SIEVE_PORT, use_tls=False, keyfile=None, certfile=None): self.host = host self.port = port self.debug = Debug self.state = 'NONAUTH' self.response_text = self.response_code = None self.__clear_knowledge() # Open socket to server. self._open(host, port) if __debug__: self._cmd_log_len = 10 self._cmd_log_idx = 0 self._cmd_log = {} # Last `_cmd_log_len' interactions if self.debug >= 1: self._mesg('managesieve version %s' % __version__) # Get server welcome message, # request and store CAPABILITY response. typ, data = self._get_response() if typ == 'OK': self._parse_capabilities(data) if use_tls and self.supports_tls: typ, data = self.starttls(keyfile=keyfile, certfile=certfile) if typ == 'OK': self._parse_capabilities(data) def _parse_capabilities(self, lines): for line in lines: if len(line) == 2: typ, data = line else: assert len(line) == 1, 'Bad Capabilities line: %r' % line typ = line[0] data = None if __debug__: if self.debug >= 3: self._mesg('%s: %r' % (typ, data)) if typ == "IMPLEMENTATION": self.implementation = data elif typ == "SASL": self.loginmechs = data.split() elif typ == "SIEVE": self.capabilities = data.split() elif typ == "STARTTLS": self.supports_tls = 1 else: # A client implementation MUST ignore any other # capabilities given that it does not understand. pass return def __getattr__(self, attr): # Allow UPPERCASE variants of MANAGESIEVE command methods. if commands.has_key(attr): return getattr(self, attr.lower()) raise AttributeError("Unknown MANAGESIEVE command: '%s'" % attr) #### Private methods ### def _open(self, host, port): """Setup 'self.sock' and 'self.file'.""" self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) self.file = self.sock.makefile('r') def _close(self): self.file.close() self.sock.close() def _read(self, size): """Read 'size' bytes from remote.""" data = "" while len(data) < size: data += self.file.read(size - len(data)) return data def _readline(self): """Read line from remote.""" return self.file.readline() def _send(self, data): return self.sock.send(data) def _get_line(self): line = self._readline() if not line: raise self.abort('socket error: EOF') # Protocol mandates all lines terminated by CRLF line = line[:-2] if __debug__: if self.debug >= 4: self._mesg('< %s' % line) else: self._log('< %s' % line) return line def _simple_command(self, *args): """Execute a command which does only return status. Returns (typ) with typ = response type The responce code and text may be found in .response_code and .response_text, respectivly. """ return self._command(*args)[0] # only return typ, ignore data def _command(self, name, arg1=None, arg2=None, *options): """ Returns (typ, data) with typ = response type data = list of lists of strings read (only meaningfull if OK) The responce code and text may be found in .response_code and .response_text, respectivly. """ if self.state not in commands[name]: raise self.error( 'Command %s illegal in state %s' % (name, self.state)) # concatinate command and arguments (if any) data = " ".join(filter(None, (name, arg1, arg2))) if __debug__: if self.debug >= 4: self._mesg('> %r' % data) else: self._log('> %s' % data) try: try: self._send('%s%s' % (data, CRLF)) for o in options: if __debug__: if self.debug >= 4: self._mesg('> %r' % o) else: self._log('> %r' % data) self._send('%s%s' % (o, CRLF)) except (socket.error, OSError), val: raise self.abort('socket error: %s' % val) return self._get_response() except self.abort, val: if __debug__: if self.debug >= 1: self.print_log() raise def _readstring(self, data): if data[0] == ' ': # space -> error raise self.error('Unexpected space: %r' % data) elif data[0] == '"': # handle double quote: if not self._match(re_dquote, data): raise self.error('Unmatched quote: %r' % data) snippet = self.mo.group(1) return re_esc_quote.sub(r'\1', snippet), data[self.mo.end():] elif self._match(Literal, data): # read a 'literal' string size = int(self.mo.group('size')) if __debug__: if self.debug >= 4: self._mesg('read literal size %s' % size) return self._read(size), self._get_line() else: data = data.split(' ', 1) if len(data) == 1: data.append('') return data def _get_response(self): """ Returns (typ, data) with typ = response type data = list of lists of strings read (only meaningfull if OK) The responce code and text may be found in .response_code and .response_text, respectivly. """ """ response-deletescript = response-oknobye response-authenticate = *(string CRLF) (response-oknobye) response-capability = *(string [SP string] CRLF) response-oknobye response-listscripts = *(string [SP "ACTIVE"] CRLF) response-oknobye response-oknobye = ("OK" / "NO" / "BYE") [SP "(" resp-code ")"] [SP string] CRLF string = quoted / literal quoted = <"> *QUOTED-CHAR <"> literal = "{" number "+}" CRLF *OCTET ;; The number represents the number of octets ;; MUST be literal-utf8 except for values --> a response either starts with a quote-charakter, a left-bracket or OK, NO, BYE "quoted" CRLF "quoted" SP "quoted" CRLF {size} CRLF *OCTETS CRLF {size} CRLF *OCTETS CRLF [A-Z-]+ CRLF """ data = [] ; dat = None resp = self._get_line() while 1: if self._match(Oknobye, resp): typ, code, dat = self.mo.group('type','code','data') if __debug__: if self.debug >= 1: self._mesg('%s response: %s %s' % (typ, code, dat)) self.response_code = code self.response_text = None if dat: self.response_text = self._readstring(dat)[0] # if server quits here, send code instead of empty data if typ == "BYE": return typ, code return typ, data ## elif 0: ## dat2 = None ## dat, resp = self._readstring(resp) ## if resp.startswith(' '): ## dat2, resp = self._readstring(resp[1:]) ## data.append( (dat, dat2)) ## resp = self._get_line() else: dat = [] while 1: dat1, resp = self._readstring(resp) if __debug__: if self.debug >= 4: self._mesg('read: %r' % (dat1,)) if self.debug >= 5: self._mesg('rest: %r' % (resp,)) dat.append(dat1) if not resp.startswith(' '): break resp = resp[1:] if len(dat) == 1: dat.append(None) data.append(dat) resp = self._get_line() return self.error('Should not come here') def _match(self, cre, s): # Run compiled regular expression match method on 's'. # Save result, return success. self.mo = cre.match(s) if __debug__: if self.mo is not None and self.debug >= 5: self._mesg("\tmatched r'%s' => %s" % (cre.pattern, `self.mo.groups()`)) return self.mo is not None if __debug__: def _mesg(self, s, secs=None): if secs is None: secs = time.time() tm = time.strftime('%M:%S', time.localtime(secs)) sys.stderr.write(' %s.%02d %s\n' % (tm, (secs*100)%100, s)) sys.stderr.flush() def _log(self, line): # Keep log of last `_cmd_log_len' interactions for debugging. self._cmd_log[self._cmd_log_idx] = (line, time.time()) self._cmd_log_idx += 1 if self._cmd_log_idx >= self._cmd_log_len: self._cmd_log_idx = 0 def print_log(self): self.self._mesg('last %d SIEVE interactions:' % len(self._cmd_log)) i, n = self._cmd_log_idx, self._cmd_log_len while n: try: self.self._mesg(*self._cmd_log[i]) except: pass i += 1 if i >= self._cmd_log_len: i = 0 n -= 1 ### Public methods ### def authenticate(self, mechanism, *authobjects): """Authenticate command - requires response processing.""" # command-authenticate = "AUTHENTICATE" SP auth-type [SP string] *(CRLF string) # response-authenticate = *(string CRLF) (response-oknobye) mech = mechanism.upper() if not mech in self.loginmechs: raise self.error("Server doesn't allow %s authentication." % mech) if mech == AUTH_LOGIN: authobjects = [ sieve_name(binascii.b2a_base64(ao)[:-1]) for ao in authobjects ] elif mech == AUTH_PLAIN: if len(authobjects) < 3: # assume authorization identity (authzid) is missing # and these two authobjects are username and password authobjects.insert(0, '') ao = '\0'.join(authobjects) ao = binascii.b2a_base64(ao)[:-1] authobjects = [ sieve_string(ao) ] else: raise self.error("managesieve doesn't support %s authentication." % mech) typ, data = self._command('AUTHENTICATE', sieve_name(mech), *authobjects) if typ == 'OK': self.state = 'AUTH' return typ def login(self, auth, user, password): """ Authenticate to the Sieve server using the best mechanism available. """ for authmech in AUTHMECHS: if authmech in self.loginmechs: authobjs = [auth, user, password] if authmech == AUTH_LOGIN: authobjs = [user, password] return self.authenticate(authmech, *authobjs) else: raise self.abort('No matching authentication mechanism found.') def logout(self): """Terminate connection to server.""" # command-logout = "LOGOUT" CRLF # response-logout = response-oknobye typ = self._simple_command('LOGOUT') self.state = 'LOGOUT' self._close() return typ def listscripts(self): """Get a list of scripts on the server. (typ, [data]) = .listscripts() if 'typ' is 'OK', 'data' is list of (scriptname, active) tuples. """ # command-listscripts = "LISTSCRIPTS" CRLF # response-listscripts = *(sieve-name [SP "ACTIVE"] CRLF) response-oknobye typ, data = self._command('LISTSCRIPTS') if typ != 'OK': return typ, data scripts = [] for dat in data: if __debug__: if not len(dat) in (1, 2): self.error("Unexpected result from LISTSCRIPTS: %r" (dat,)) scripts.append( (dat[0], dat[1] is not None )) return typ, scripts def getscript(self, scriptname): """Get a script from the server. (typ, scriptdata) = .getscript(scriptname) 'scriptdata' is the script data. """ # command-getscript = "GETSCRIPT" SP sieve-name CRLF # response-getscript = [string CRLF] response-oknobye typ, data = self._command('GETSCRIPT', sieve_name(scriptname)) if typ != 'OK': return typ, data if len(data) != 1: self.error('GETSCRIPT returned more than one string/script') # todo: decode data? return typ, data[0][0] def putscript(self, scriptname, scriptdata): """Put a script onto the server.""" # command-putscript = "PUTSCRIPT" SP sieve-name SP string CRLF # response-putscript = response-oknobye return self._simple_command('PUTSCRIPT', sieve_name(scriptname), sieve_string(scriptdata) ) def deletescript(self, scriptname): """Delete a scripts at the server.""" # command-deletescript = "DELETESCRIPT" SP sieve-name CRLF # response-deletescript = response-oknobye return self._simple_command('DELETESCRIPT', sieve_name(scriptname)) def setactive(self, scriptname): """Mark a script as the 'active' one.""" # command-setactive = "SETACTIVE" SP sieve-name CRLF # response-setactive = response-oknobye return self._simple_command('SETACTIVE', sieve_name(scriptname)) def havespace(self, scriptname, size): # command-havespace = "HAVESPACE" SP sieve-name SP number CRLF # response-havespace = response-oknobye return self._simple_command('HAVESPACE', sieve_name(scriptname), str(size)) def capability(self): """ Isse a CAPABILITY command and return the result. As a side-effect, on succes these attributes are (re)set: self.implementation self.loginmechs self.capabilities self.supports_tls """ # command-capability = "CAPABILITY" CRLF # response-capability = *(string [SP string] CRLF) response-oknobye typ, data = self._command('CAPABILITY') if typ == 'OK': self._parse_capabilities(data) return typ, data def starttls(self, keyfile=None, certfile=None): """Puts the connection to the SIEVE server into TLS mode. If the server supports TLS, this will encrypt the rest of the SIEVE session. If you provide the keyfile and certfile parameters, the identity of the SIEVE server and client can be checked. This, however, depends on whether the socket module really checks the certificates. """ # command-starttls = "STARTTLS" CRLF # response-starttls = response-oknobye typ, data = self._command('STARTTLS') if typ == 'OK': sslobj = ssl_wrap_socket(self.sock, keyfile, certfile) self.sock = SSLFakeSocket(self.sock, sslobj) self.file = SSLFakeFile(sslobj) # MUST discard knowledge obtained from the server self.__clear_knowledge() # Some servers send capabilities after TLS handshake, some # do not. We send a bogus command, and expect a NO. If you # get something else instead, read the extra NO to clear # the buffer. typ, data = self._command('BOGUS') if typ != 'NO': typ, data = self._get_response() # server may not advertise capabilities, thus we need to ask self.capability() if self.debug >= 3: self._mesg('started Transport Layer Security (TLS)') return typ, data