diff options
author | Jeroen van Meeuwen (Kolab Systems) <vanmeeuwen@kolabsys.com> | 2012-11-18 17:28:43 (GMT) |
---|---|---|
committer | Jeroen van Meeuwen (Kolab Systems) <vanmeeuwen@kolabsys.com> | 2012-11-18 17:28:43 (GMT) |
commit | 092eed219ad6863e6ce911265223ce7c1b50c01d (patch) | |
tree | 5a4ae9a1e1e29069db9e335dae5d5a01b1ef136c | |
download | python-sievelib-092eed219ad6863e6ce911265223ce7c1b50c01d.tar.gz |
Add original sievelib-0.5 releaseupstream-0.5
-rw-r--r-- | COPYING | 13 | ||||
-rw-r--r-- | MANIFEST.in | 2 | ||||
-rw-r--r-- | PKG-INFO | 122 | ||||
-rw-r--r-- | README.rst | 104 | ||||
-rw-r--r-- | setup.cfg | 5 | ||||
-rw-r--r-- | setup.py | 24 | ||||
-rw-r--r-- | sievelib.egg-info/PKG-INFO | 122 | ||||
-rw-r--r-- | sievelib.egg-info/SOURCES.txt | 14 | ||||
-rw-r--r-- | sievelib.egg-info/dependency_links.txt | 1 | ||||
-rw-r--r-- | sievelib.egg-info/top_level.txt | 1 | ||||
-rw-r--r-- | sievelib/__init__.py | 0 | ||||
-rw-r--r-- | sievelib/commands.py | 619 | ||||
-rw-r--r-- | sievelib/digest_md5.py | 69 | ||||
-rw-r--r-- | sievelib/factory.py | 291 | ||||
-rw-r--r-- | sievelib/managesieve.py | 639 | ||||
-rwxr-xr-x | sievelib/parser.py | 438 |
16 files changed, 2464 insertions, 0 deletions
@@ -0,0 +1,13 @@ +Copyright (c) 2011-2012, Antoine Nguyen <tonio@ngyn.org> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9491f80 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.rst +include COPYING diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..56ca998 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,122 @@ +Metadata-Version: 1.1 +Name: sievelib +Version: 0.5 +Summary: Client-side SIEVE library +Home-page: http://bitbucket.org/tonioo/sievelib +Author: Antoine Nguyen +Author-email: tonio@ngyn.org +License: MIT +Description: sievelib + ======== + + Client-side Sieve and Managesieve library written in Python. + + * Sieve : An Email Filtering Language + (`RFC 5228 <http://tools.ietf.org/html/rfc5228>`_) + * ManageSieve : A Protocol for Remotely Managing Sieve Scripts + (`Draft <http://tools.ietf.org/html/draft-martin-managesieve-12>`_) + + Sieve tools + ----------- + + What is supported + ^^^^^^^^^^^^^^^^^ + + Currently, the provided parser supports most of the functionalities + described in the RFC. The only exception concerns section + *2.4.2.4. Encoding Characters Using "encoded-character"* which is not + supported. + + The following extensions are also supported: + + * Vacation (`RFC 5230 <http://tools.ietf.org/html/rfc5230>`_) + + Basic usage + ^^^^^^^^^^^ + + The parser can either be used from the command-line:: + + $ cd sievelib + $ python parser.py test.sieve + Syntax OK + $ + + Or can be used from a python environment (or script/module):: + + >>> from sievelib.parser import Parser + >>> p = Parser() + >>> p.parse('require ["fileinto"];') + True + >>> p.dump() + require (type: control) + ["fileinto"] + >>> + >>> p.parse('require ["fileinto"]') + False + >>> p.error + 'line 1: parsing error: end of script reached while semicolon expected' + >>> + + Simple filters creation + ^^^^^^^^^^^^^^^^^^^^^^^ + + Some high-level classes are provided with the ``factory`` module, they + make the generation of Sieve rules easier:: + + >>> from sievelib.factory import FilterSet + >>> fs.addfilter("rule1", + ... [("Sender", ":is", "toto@toto.com"),], + ... [("fileinto", "Toto"),]) + >>> fs.tosieve() + require ["fileinto"]; + + # Filter: rule1 + if anyof (header :is "Sender" "toto@toto.com") { + fileinto "Toto"; + } + >>> + + Additionnal documentation is available with source code. + + ManageSieve tools + ----------------- + + What is supported + ^^^^^^^^^^^^^^^^^ + + All mandatory commands are supported. The ``RENAME`` extension is + supported, with a simulated behaviour for server that do not support + it. + + For the ``AUTHENTICATE`` command, supported mechanisms are ``DIGEST-MD5``, + ``PLAIN`` and ``LOGIN``. + + Basic usage + ^^^^^^^^^^^ + + The ManageSieve client is intended to be used from another python + application (there isn't any shell provided):: + + >>> from sievelib.managesieve import Client + >>> c = Client("server.example.com") + >>> c.connect("user", "password", starttls=False, authmech="DIGEST-MD5") + True + >>> c.listscripts() + ("active_script", ["script1", "script2"]) + >>> c.setactive("script1") + True + >>> c.havespace("script3", 45) + True + >>> + + Additionnal documentation is available with source code. + +Keywords: sieve,managesieve,parser,client +Platform: UNKNOWN +Classifier: Programming Language :: Python +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Communications :: Email :: Filters diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..f65bcc4 --- /dev/null +++ b/README.rst @@ -0,0 +1,104 @@ +sievelib +======== + +Client-side Sieve and Managesieve library written in Python. + +* Sieve : An Email Filtering Language + (`RFC 5228 <http://tools.ietf.org/html/rfc5228>`_) +* ManageSieve : A Protocol for Remotely Managing Sieve Scripts + (`Draft <http://tools.ietf.org/html/draft-martin-managesieve-12>`_) + +Sieve tools +----------- + +What is supported +^^^^^^^^^^^^^^^^^ + +Currently, the provided parser supports most of the functionalities +described in the RFC. The only exception concerns section +*2.4.2.4. Encoding Characters Using "encoded-character"* which is not +supported. + +The following extensions are also supported: + +* Vacation (`RFC 5230 <http://tools.ietf.org/html/rfc5230>`_) + +Basic usage +^^^^^^^^^^^ + +The parser can either be used from the command-line:: + + $ cd sievelib + $ python parser.py test.sieve + Syntax OK + $ + +Or can be used from a python environment (or script/module):: + + >>> from sievelib.parser import Parser + >>> p = Parser() + >>> p.parse('require ["fileinto"];') + True + >>> p.dump() + require (type: control) + ["fileinto"] + >>> + >>> p.parse('require ["fileinto"]') + False + >>> p.error + 'line 1: parsing error: end of script reached while semicolon expected' + >>> + +Simple filters creation +^^^^^^^^^^^^^^^^^^^^^^^ + +Some high-level classes are provided with the ``factory`` module, they +make the generation of Sieve rules easier:: + + >>> from sievelib.factory import FilterSet + >>> fs.addfilter("rule1", + ... [("Sender", ":is", "toto@toto.com"),], + ... [("fileinto", "Toto"),]) + >>> fs.tosieve() + require ["fileinto"]; + + # Filter: rule1 + if anyof (header :is "Sender" "toto@toto.com") { + fileinto "Toto"; + } + >>> + +Additionnal documentation is available with source code. + +ManageSieve tools +----------------- + +What is supported +^^^^^^^^^^^^^^^^^ + +All mandatory commands are supported. The ``RENAME`` extension is +supported, with a simulated behaviour for server that do not support +it. + +For the ``AUTHENTICATE`` command, supported mechanisms are ``DIGEST-MD5``, +``PLAIN`` and ``LOGIN``. + +Basic usage +^^^^^^^^^^^ + +The ManageSieve client is intended to be used from another python +application (there isn't any shell provided):: + + >>> from sievelib.managesieve import Client + >>> c = Client("server.example.com") + >>> c.connect("user", "password", starttls=False, authmech="DIGEST-MD5") + True + >>> c.listscripts() + ("active_script", ["script1", "script2"]) + >>> c.setactive("script1") + True + >>> c.havespace("script3", 45) + True + >>> + +Additionnal documentation is available with source code. diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..861a9f5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[egg_info] +tag_build = +tag_date = 0 +tag_svn_revision = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..93159c7 --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +from setuptools import setup + +setup( + name = "sievelib", + packages = ["sievelib"], + version = "0.5", + description = "Client-side SIEVE library", + author = "Antoine Nguyen", + author_email = "tonio@ngyn.org", + url = "http://bitbucket.org/tonioo/sievelib", + license = "MIT", + keywords = ["sieve", "managesieve", "parser", "client"], + classifiers = [ + "Programming Language :: Python", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Communications :: Email :: Filters" + ], + + long_description = open("README.rst").read() +) diff --git a/sievelib.egg-info/PKG-INFO b/sievelib.egg-info/PKG-INFO new file mode 100644 index 0000000..56ca998 --- /dev/null +++ b/sievelib.egg-info/PKG-INFO @@ -0,0 +1,122 @@ +Metadata-Version: 1.1 +Name: sievelib +Version: 0.5 +Summary: Client-side SIEVE library +Home-page: http://bitbucket.org/tonioo/sievelib +Author: Antoine Nguyen +Author-email: tonio@ngyn.org +License: MIT +Description: sievelib + ======== + + Client-side Sieve and Managesieve library written in Python. + + * Sieve : An Email Filtering Language + (`RFC 5228 <http://tools.ietf.org/html/rfc5228>`_) + * ManageSieve : A Protocol for Remotely Managing Sieve Scripts + (`Draft <http://tools.ietf.org/html/draft-martin-managesieve-12>`_) + + Sieve tools + ----------- + + What is supported + ^^^^^^^^^^^^^^^^^ + + Currently, the provided parser supports most of the functionalities + described in the RFC. The only exception concerns section + *2.4.2.4. Encoding Characters Using "encoded-character"* which is not + supported. + + The following extensions are also supported: + + * Vacation (`RFC 5230 <http://tools.ietf.org/html/rfc5230>`_) + + Basic usage + ^^^^^^^^^^^ + + The parser can either be used from the command-line:: + + $ cd sievelib + $ python parser.py test.sieve + Syntax OK + $ + + Or can be used from a python environment (or script/module):: + + >>> from sievelib.parser import Parser + >>> p = Parser() + >>> p.parse('require ["fileinto"];') + True + >>> p.dump() + require (type: control) + ["fileinto"] + >>> + >>> p.parse('require ["fileinto"]') + False + >>> p.error + 'line 1: parsing error: end of script reached while semicolon expected' + >>> + + Simple filters creation + ^^^^^^^^^^^^^^^^^^^^^^^ + + Some high-level classes are provided with the ``factory`` module, they + make the generation of Sieve rules easier:: + + >>> from sievelib.factory import FilterSet + >>> fs.addfilter("rule1", + ... [("Sender", ":is", "toto@toto.com"),], + ... [("fileinto", "Toto"),]) + >>> fs.tosieve() + require ["fileinto"]; + + # Filter: rule1 + if anyof (header :is "Sender" "toto@toto.com") { + fileinto "Toto"; + } + >>> + + Additionnal documentation is available with source code. + + ManageSieve tools + ----------------- + + What is supported + ^^^^^^^^^^^^^^^^^ + + All mandatory commands are supported. The ``RENAME`` extension is + supported, with a simulated behaviour for server that do not support + it. + + For the ``AUTHENTICATE`` command, supported mechanisms are ``DIGEST-MD5``, + ``PLAIN`` and ``LOGIN``. + + Basic usage + ^^^^^^^^^^^ + + The ManageSieve client is intended to be used from another python + application (there isn't any shell provided):: + + >>> from sievelib.managesieve import Client + >>> c = Client("server.example.com") + >>> c.connect("user", "password", starttls=False, authmech="DIGEST-MD5") + True + >>> c.listscripts() + ("active_script", ["script1", "script2"]) + >>> c.setactive("script1") + True + >>> c.havespace("script3", 45) + True + >>> + + Additionnal documentation is available with source code. + +Keywords: sieve,managesieve,parser,client +Platform: UNKNOWN +Classifier: Programming Language :: Python +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Communications :: Email :: Filters diff --git a/sievelib.egg-info/SOURCES.txt b/sievelib.egg-info/SOURCES.txt new file mode 100644 index 0000000..ed1b128 --- /dev/null +++ b/sievelib.egg-info/SOURCES.txt @@ -0,0 +1,14 @@ +COPYING +MANIFEST.in +README.rst +setup.py +sievelib/__init__.py +sievelib/commands.py +sievelib/digest_md5.py +sievelib/factory.py +sievelib/managesieve.py +sievelib/parser.py +sievelib.egg-info/PKG-INFO +sievelib.egg-info/SOURCES.txt +sievelib.egg-info/dependency_links.txt +sievelib.egg-info/top_level.txt
\ No newline at end of file diff --git a/sievelib.egg-info/dependency_links.txt b/sievelib.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sievelib.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/sievelib.egg-info/top_level.txt b/sievelib.egg-info/top_level.txt new file mode 100644 index 0000000..f789786 --- /dev/null +++ b/sievelib.egg-info/top_level.txt @@ -0,0 +1 @@ +sievelib diff --git a/sievelib/__init__.py b/sievelib/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/sievelib/__init__.py diff --git a/sievelib/commands.py b/sievelib/commands.py new file mode 100644 index 0000000..6a4b0de --- /dev/null +++ b/sievelib/commands.py @@ -0,0 +1,619 @@ +# coding: utf-8 + +""" +SIEVE commands representation + +This module contains classes that represent known commands. They all +inherit from the Command class which provides generic method for +command manipulation or parsing. + +There are three command types (each one represented by a class): + * control (ControlCommand) : Control structures are needed to allow + for multiple and conditional actions + * action (ActionCommand) : Actions that can be applied on emails + * test (TestCommand) : Tests are used in conditionals to decide which + part(s) of the conditional to execute + +Finally, each known command is represented by its own class which +provides extra information such as: + * expected arguments, + * completion callback, + * etc. + +""" +import sys + +class UnknownCommand(Exception): + """Specific exception raised when an unknown command is encountered""" + def __init__(self, name): + self.name = name + + def __str__(self): + return "unknown command %s" % self.name + +class BadArgument(Exception): + """Specific exception raised when a bad argument is encountered""" + def __init__(self, command, seen, expected): + self.command = command + self.seen = seen + self.expected = expected + + def __str__(self): + return "bad argument %s for command %s (%s expected)" \ + % (self.seen, self.command, self.expected) + +class BadValue(Exception): + """Specific exception raised when a bad argument value is encountered""" + def __init__(self, argument, value): + self.argument = argument + self.value = value + + def __str__(self): + return "bad value %s for argument %s" \ + % (self.value, self.argument) + + +# Statement elements (see RFC, section 8.3) +# They are used in different commands. +comparator = {"name" : "comparator", + "type" : ["tag"], + "values" : [":comparator"], + "extra_arg" : {"type" : "string", + "values" : ['"i;octet"', '"i;ascii-casemap"']}, + "required" : False} +address_part = {"name" : "address-part", + "values" : [":localpart", ":domain", ":all"], + "type" : ["tag"], + "required" : False} +match_type = {"name" : "match-type", + "values" : [":is", ":contains", ":matches"], + "type" : ["tag"], + "required" : False} + +class Command(object): + """Generic command representation. + + A command is described as follow: + * A name + * A type + * A description of supported arguments + * Does it accept an unkwown quantity of arguments? (ex: anyof, allof) + * Does it accept children? (ie. subcommands) + * Is it an extension? + * Must follow only certain commands + + """ + _type = None + variable_args_nb = False + accept_children = False + must_follow = None + is_extension = False + + def __init__(self, parent=None): + self.parent = parent + self.arguments = {} + self.children = [] + + self.nextargpos = 0 + self.required_args = -1 + self.rargs_cnt = 0 + self.curarg = None # for arguments that expect an argument :p (ex: :comparator) + + self.name = self.__class__.__name__.replace("Command", "") + self.name = self.name.lower() + + def __repr__(self): + return "%s (type: %s)" % (self.name, self._type) + + def tosieve(self, indentlevel=0, target=sys.stdout): + """Generate the sieve syntax corresponding to this command + + Recursive method. + + :param indentlevel: current indentation level + :param target: opened file pointer where the content will be printed + """ + self.__print(self.name, indentlevel, nocr=True, target=target) + if self.has_arguments(): + for arg in self.args_definition: + if not self.arguments.has_key(arg["name"]): + continue + target.write(" ") + value = self.arguments[arg["name"]] + if type(value) == list: + if self.__get_arg_type(arg["name"]) == ["testlist"]: + target.write("(") + for t in value: + t.tosieve(target=target) + if value.index(t) != len(value) - 1: + target.write(", ") + target.write(")") + else: + target.write("[" + (", ".join(map(lambda v: '"%s"' % v.strip('"'), value))) + "]") + continue + if isinstance(value, Command): + value.tosieve(indentlevel, target=target) + continue + + if type(value) is unicode: + value = value.encode("utf-8") + + if "tag" in arg["type"] and arg.get("write_tag", False): + target.write("%s " % arg["values"][0]) + + if "string" in arg["type"]: + target.write('"%s"' % value.strip('"')) + else: + target.write(value) + + if not self.accept_children: + if self.get_type() != "test": + print >>target, ";" + return + if self.get_type() != "control": + return + print >>target, " {" + for ch in self.children: + ch.tosieve(indentlevel + 4, target=target) + self.__print("}", indentlevel, target=target) + + def __print(self, data, indentlevel, nocr=False, target=sys.stdout): + text = "%s%s" % (" " * indentlevel, data) + if nocr: + target.write(text) + else: + print >>target, text + + def __get_arg_type(self, arg): + """Return the type corresponding to the given name. + + :param arg: a defined argument name + """ + for a in self.args_definition: + if a["name"] == arg: + return a["type"] + return None + + def complete_cb(self): + """Completion callback + + Called when a command is considered as complete by the parser. + """ + pass + + def get_expected_first(self): + """Return the first expected token for this command""" + return None + + def has_arguments(self): + return len(self.args_definition) != 0 + + def dump(self, indentlevel=0, target=sys.stdout): + """Display the command + + Pretty printing of this command and its eventual arguments and + children. (recursively) + + :param indentlevel: integer that indicates indentation level to apply + """ + self.__print(self, indentlevel, target=target) + indentlevel += 4 + if self.has_arguments(): + for arg in self.args_definition: + if not self.arguments.has_key(arg["name"]): + continue + value = self.arguments[arg["name"]] + if type(value) == list: + if self.__get_arg_type(arg["name"]) == ["testlist"]: + for t in value: + t.dump(indentlevel, target) + else: + self.__print("[" + (",".join(value)) + "]", indentlevel, target=target) + continue + if isinstance(value, Command): + value.dump(indentlevel, target) + continue + self.__print(str(value), indentlevel, target=target) + for ch in self.children: + ch.dump(indentlevel, target) + + def addchild(self, child): + """Add a new child to the command + + A child corresponds to a command located into a block (this + command's block). It can be either an action or a control. + + :param child: the new child + :return: True on succes, False otherwise + """ + if not self.accept_children: + return False + self.children += [child] + return True + + def iscomplete(self): + """Check if the command is complete + + Check if all required arguments have been encountered. For + commands that allow an undefined number of arguments, this + method always returns False. + + :return: True if command is complete, False otherwise + """ + if self.variable_args_nb: + return False + if self.required_args == -1: + self.required_args = 0 + for arg in self.args_definition: + if arg["required"]: + self.required_args += 1 + return self.rargs_cnt == self.required_args + + def get_type(self): + """Return the command's type""" + if self._type is None: + raise NotImplemented + return self._type + + def __is_valid_value_for_arg(self, arg, value): + """Check if value is allowed for arg + + Some commands only allow a limited set of values. The method + always returns True for methods that do not provide such a + set. + + :param arg: the argument's name + :param value: the value to check + :return: True on succes, False otherwise + """ + if not arg.has_key("values"): + return True + return value.lower() in arg["values"] + + def check_next_arg(self, atype, avalue, add=True): + """Argument validity checking + + This method is usually used by the parser to check if detected + argument is allowed for this command. + + We make a distinction between required and optional + arguments. Optional (or tagged) arguments can be provided + unordered but the required ones. + + A special handling is also for arguments that require an + argument (example: the :comparator argument expects a string + argument). + + The "testlist" type is checked separately as we can't know in + advance how many arguments will be provided. + + If the argument is incorrect, the method raises the + appropriate exception, or return False to let the parser + handle the exception. + + :param atype: the argument's type + :param avalue: the argument's value + :param add: indicates if this argument should be recorded on success + :return: True on success, False otherwise + """ + if not self.has_arguments(): + return False + if self.iscomplete(): + return False + + if self.curarg is not None: + if atype == self.curarg["extra_arg"]["type"]: + if not self.curarg["extra_arg"].has_key("values") or \ + avalue in self.curarg["extra_arg"]["values"]: + if add: + self.arguments[self.curarg["name"]] = avalue + self.curarg = None + return True + raise BadValue(self.curarg["name"], avalue) + + failed = False + pos = self.nextargpos + while pos < len(self.args_definition): + curarg = self.args_definition[pos] + if curarg["required"]: + if curarg["type"] == ["testlist"]: + if atype != "test": + failed = True + elif add: + if not self.arguments.has_key(curarg["name"]): + self.arguments[curarg["name"]] = [] + self.arguments[curarg["name"]] += [avalue] + elif atype not in curarg["type"] or \ + not self.__is_valid_value_for_arg(curarg, avalue): + failed = True + else: + self.rargs_cnt += 1 + self.nextargpos = pos + 1 + if add: + self.arguments[curarg["name"]] = avalue + break + + if atype in curarg["type"]: + if self.__is_valid_value_for_arg(curarg, avalue): + if curarg.has_key("extra_arg"): + self.curarg = curarg + break + if add: + self.arguments[curarg["name"]] = avalue + break + + pos += 1 + + if failed: + raise BadArgument(self.name, avalue, + self.args_definition[pos]["type"]) + return True + + def __getitem__(self, name): + """Shorcut to access a command argument + + :param name: the argument's name + """ + found = False + for ad in self.args_definition: + if ad["name"] == name: + found = True + break + if not found: + raise KeyError(name) + if not self.arguments.has_key(name): + raise KeyError(name) + return self.arguments[name] + +class ControlCommand(Command): + """Indermediate class to represent "control" commands""" + _type = "control" + +class RequireCommand(ControlCommand): + """The 'require' command + + This class has one big difference with others as it is used to + store loaded extension names. (The result is we can check for + unloaded extensions during the parsing) + """ + args_definition = [ + {"name" : "capabilities", + "type" : ["string", "stringlist"], + "required" : True} + ] + + loaded_extensions = [] + + def complete_cb(self): + if type(self.arguments["capabilities"]) == str: + exts = [self.arguments["capabilities"]] + else: + exts = self.arguments["capabilities"] + for ext in exts: + ext = ext.strip('"') + if not ext in RequireCommand.loaded_extensions: + RequireCommand.loaded_extensions += [ext] + +class StopCommand(ControlCommand): + args_definition = [] + +class IfCommand(ControlCommand): + accept_children = True + + args_definition = [ + {"name" : "test", + "type" : ["test"], + "required" : True} + ] + + def get_expected_first(self): + return ["identifier"] + +class ElsifCommand(ControlCommand): + accept_children = True + must_follow = ["if", "elsif"] + args_definition = [ + {"name" : "test", + "type" : ["test"], + "required" : True} + ] + + def get_expected_first(self): + return ["identifier"] + +class ElseCommand(ControlCommand): + accept_children = True + must_follow = ["if", "elsif"] + args_definition = [] + +class ActionCommand(Command): + """Indermediate class to represent "action" commands""" + _type = "action" + +class FileintoCommand(ActionCommand): + is_extension = True + args_definition = [ + {"name" : "mailbox", + "type" : ["string"], + "required" : True} + ] + +class RedirectCommand(ActionCommand): + args_definition = [ + {"name" : "address", + "type" : ["string"], + "required" : True} + ] + +class KeepCommand(ActionCommand): + args_definition = [] + +class DiscardCommand(ActionCommand): + args_definition = [] + +class TestCommand(Command): + """Indermediate class to represent "test" commands""" + _type = "test" + +class AddressCommand(TestCommand): + args_definition = [ + comparator, + address_part, + match_type, + {"name" : "header-list", + "type" : ["string", "stringlist"], + "required" : True}, + {"name" : "key-list", + "type" : ["string", "stringlist"], + "required" : True} + ] + +class AllofCommand(TestCommand): + accept_children = True + variable_args_nb = True + + args_definition = [ + {"name" : "tests", + "type" : ["testlist"], + "required" : True} + ] + + def get_expected_first(self): + return ["left_parenthesis"] + +class AnyofCommand(TestCommand): + accept_children = True + variable_args_nb = True + + args_definition = [ + {"name" : "tests", + "type" : ["testlist"], + "required" : True} + ] + + def get_expected_first(self): + return ["left_parenthesis"] + +class EnvelopeCommand(TestCommand): + args_definition = [ + comparator, + address_part, + match_type, + {"name" : "header-list", + "type" : ["string", "stringlist"], + "required" : True}, + {"name" : "key-list", + "type" : ["string", "stringlist"], + "required" : True} + ] + +class ExistsCommand(TestCommand): + args_definition = [ + {"name" : "header-names", + "type" : ["stringlist"], + "required" : True} + ] + +class TrueCommand(TestCommand): + args_definition = [] + +class FalseCommand(TestCommand): + args_definition = [] + +class HeaderCommand(TestCommand): + args_definition = [ + comparator, + match_type, + {"name" : "header-names", + "type" : ["string", "stringlist"], + "required" : True}, + {"name" : "key-list", + "type" : ["string", "stringlist"], + "required" : True} + ] + +class NotCommand(TestCommand): + accept_children = True + + args_definition = [ + {"name" : "test", + "type" : ["test"], + "required" : True} + ] + + def get_expected_first(self): + return ["identifier"] + +class SizeCommand(TestCommand): + args_definition = [ + {"name" : "comparator", + "type" : ["tag"], + "values" : [":over", ":under"], + "required" : True}, + {"name" : "limit", + "type" : ["number"], + "required" : True}, + ] + +class VacationCommand(ActionCommand): + args_definition = [ + {"name" : "subject", + "type" : ["tag"], + "write_tag": True, + "values" : [":subject"], + "extra_arg" : {"type" : "string"}, + "required" : False}, + {"name" : "days", + "type" : ["tag"], + "write_tag": True, + "values" : [":days"], + "extra_arg" : {"type" : "number"}, + "required" : False}, + {"name" : "from", + "type" : ["tag"], + "write_tag": True, + "values" : [":from"], + "extra_arg" : {"type" : "string"}, + "required" : False}, + {"name" : "addresses", + "type" : ["tag"], + "write_tag": True, + "values" : [":addresses"], + "extra_arg" : {"type" : "stringlist"}, + "required" : False}, + {"name" : "handle", + "type" : ["tag"], + "write_tag": True, + "values" : [":handle"], + "extra_arg" : {"type" : "string"}, + "required" : False}, + {"name" : "mime", + "type" : ["tag"], + "write_tag": True, + "values" : [":mime"], + "required" : False}, + {"name" : "reason", + "type" : ["string"], + "required" : True}, + ] + + +def get_command_instance(name, parent=None, checkexists=True): + """Try to guess and create the appropriate command instance + + Given a command name (encountered by the parser), construct the + associated class name and, if known, return a new instance. + + If the command is not known or has not been loaded using require, + an UnknownCommand exception is raised. + + :param name: the command's name + :param parent: the eventual parent command + :return: a new class instance + """ + cname = "%sCommand" % name.lower().capitalize() + if not globals().has_key(cname) or \ + (checkexists and globals()[cname].is_extension and \ + not name in RequireCommand.loaded_extensions): + raise UnknownCommand(name) + return globals()[cname](parent) diff --git a/sievelib/digest_md5.py b/sievelib/digest_md5.py new file mode 100644 index 0000000..9011177 --- /dev/null +++ b/sievelib/digest_md5.py @@ -0,0 +1,69 @@ +# coding: utf-8 + +""" +Simple Digest-MD5 implementation (client side) + +Implementation based on RFC 2831 (http://www.ietf.org/rfc/rfc2831.txt) +""" + +import base64 +import hashlib +import binascii +import re +import random + +class DigestMD5(object): + def __init__(self, challenge, digesturi): + self.__digesturi = digesturi + self.__challenge = challenge + + self.__params = {} + pexpr = re.compile('(\w+)="(.+)"') + for elt in base64.b64decode(challenge).split(","): + m = pexpr.match(elt) + if m is None: + continue + self.__params[m.group(1)] = m.group(2) + + def __make_cnonce(self): + ret = "" + for i in xrange(12): + ret += chr(random.randint(0, 0xff)) + return base64.b64encode(ret) + + def __digest(self, value): + return hashlib.md5(value).digest() + + def __hexdigest(self, value): + return binascii.hexlify(hashlib.md5(value).digest()) + + def __make_response(self, username, password, check=False): + a1 = "%s:%s:%s" % (self.__digest("%s:%s:%s" % (username, self.realm, password)), + self.__params["nonce"], self.cnonce) + if check: + a2 = ":%s" % self.__digesturi + else: + a2 = "AUTHENTICATE:%s" % self.__digesturi + resp = "%s:%s:00000001:%s:auth:%s" \ + % (self.__hexdigest(a1), self.__params["nonce"], + self.cnonce, self.__hexdigest(a2)) + + return self.__hexdigest(resp) + + def response(self, username, password): + self.realm = self.__params["realm"] if self.__params.has_key("realm") else "" + self.cnonce = self.__make_cnonce() + respvalue = self.__make_response(username, password) + + dgres = 'username="%s",%snonce="%s",cnonce="%s",nc=00000001,qop=auth,' \ + 'digest-uri="%s",response=%s' \ + % (username, ('realm="%s",' % self.realm) if len(self.realm) else "", + self.__params["nonce"], self.cnonce, self.__digesturi, respvalue) + + return base64.b64encode(dgres) + + def check_last_challenge(self, username, password, value): + challenge = base64.b64decode(value.strip('"')) + return challenge == \ + ("rspauth=%s" % self.__make_response(username, password, True)) + diff --git a/sievelib/factory.py b/sievelib/factory.py new file mode 100644 index 0000000..4231844 --- /dev/null +++ b/sievelib/factory.py @@ -0,0 +1,291 @@ +# coding: utf-8 + +""" +Tools for simpler sieve filters generation. + +This module is intented to facilitate the creation of sieve filters +without having to write or to know the syntax. + +Only commands (control/test/action) defined in the ``commands`` module +are supported. +""" +import sys +import cStringIO +from commands import * + +class FiltersSet(object): + def __init__(self, name): + self.name = name + self.requires = [] + self.filters = [] + + def __str__(self): + target = cStringIO.StringIO() + self.tosieve(target) + ret = target.getvalue() + target.close() + return ret + + def __isdisabled(self, fcontent): + """Tells if a filter is disabled or not + + Simply checks if the filter is surrounded by a "if false" test. + + :param name: the filter's name + """ + if not isinstance(fcontent, IfCommand): + return False + if not isinstance(fcontent["test"], FalseCommand): + return False + return True + + def from_parser_result(self, parser): + cpt = 1 + for f in parser.result: + if isinstance(f, RequireCommand): + if type(f.arguments["capabilities"]) == list: + map(self.require, f.arguments["capabilities"]) + else: + self.require(f.arguments["capabilities"]) + continue + if cpt - 1 >= len(parser.hash_comments): + name = "Unnamed rule %d" % cpt + else: + name = parser.hash_comments[cpt - 1].replace("# Filter: ", "") + self.filters += [{"name" : name, + "content" : f, + "enabled" : not self.__isdisabled(f)}] + cpt += 1 + + def require(self, name): + """Add a new extension to the requirements list + + :param name: the extension's name + """ + name = name.strip('"') + if not name in self.requires: + self.requires += [name] + + def __gen_require_command(self): + """Internal method to create a RequireCommand based on requirements + + Called just before this object is going to be dumped. + """ + reqcmd = get_command_instance("require") + reqcmd.check_next_arg("stringlist", self.requires) + return reqcmd + + def __create_filter(self, conditions, actions, matchtype="anyof"): + """Create a new filter + + A filter is composed of: + * a name + * one or more conditions (tests) combined together using ``matchtype`` + * one or more actions + + A condition must be given as a 3-uple of the form:: + + (test's name, operator, value) + + An action must be given as a 2-uple of the form:: + + (action's name, value) + + It uses the "header" test to generate the sieve syntax + corresponding to the given conditions. + + :param conditions: the list of conditions + :param actions: the list of actions + :param matchtype: "anyof" or "allof" + """ + ifcontrol = get_command_instance("if") + mtypeobj = get_command_instance(matchtype, ifcontrol) + for c in conditions: + if c[0] in ("true", "false"): + cmd = get_command_instance(c[0], ifcontrol) + elif c[0] == "size": + cmd = get_command_instance("size", ifcontrol) + cmd.check_next_arg("tag", c[1]) + cmd.check_next_arg("number", c[2]) + else: + cmd = get_command_instance("header", ifcontrol) + cmd.check_next_arg("tag", c[1]) + cmd.check_next_arg("string", c[0]) + cmd.check_next_arg("string", c[2]) + mtypeobj.check_next_arg("test", cmd) + ifcontrol.check_next_arg("test", mtypeobj) + + for actdef in actions: + self.require(actdef[0]) + action = get_command_instance(actdef[0], ifcontrol, False) + for arg in actdef[1:]: + action.check_next_arg("string", arg) + ifcontrol.addchild(action) + return ifcontrol + + def addfilter(self, name, conditions, actions, matchtype="anyof"): + """Add a new filter to this filters set + + :param name: the filter's name + :param conditions: the list of conditions + :param actions: the list of actions + :param matchtype: "anyof" or "allof" + """ + ifcontrol = self.__create_filter(conditions, actions, matchtype) + self.filters += [{"name" : name, "content" : ifcontrol, "enabled" : True}] + + def updatefilter(self, oldname, newname, conditions, actions, matchtype="anyof"): + """Update a specific filter + + Instead of removing and re-creating the filter, we update the + content in order to keep the original ordre between filters. + + :param name: the filter's name + :param conditions: the list of conditions + :param actions: the list of actions + :param matchtype: "anyof" or "allof" + """ + for f in self.filters: + if f["name"] == oldname: + f["name"] = newname + f["content"] = \ + self.__create_filter(conditions, actions, matchtype) + if not f["enabled"]: + return self.disablefilter(newname) + return True + return False + + def getfilter(self, name): + """Search for a specific filter + + :param name: the filter's name + :return: the Command object if found, None otherwise + """ + for f in self.filters: + if f["name"] == name: + if not f["enabled"]: + return f["content"].children[0] + return f["content"] + return None + + def removefilter(self, name): + """Remove a specific filter + + :param name: the filter's name + """ + for f in self.filters: + if f["name"] == name: + self.filters.remove(f) + return True + return False + + def enablefilter(self, name): + """Enable a filter + + Just removes the "if false" test surrouding this filter. + + :param name: the filter's name + """ + for f in self.filters: + if f["name"] != name: + continue + if not self.__isdisabled(f["content"]): + return False + f["content"] = f["content"].children[0] + f["enabled"] = True + return True + return False # raise NotFound + + def is_filter_disabled(self, name): + """Tells if the filter is currently disabled or not + + :param name: the filter's name + """ + for f in self.filters: + if f["name"] == name: + return self.__isdisabled(f["content"]) + return True + + def disablefilter(self, name): + """Disable a filter + + Instead of commenting the filter, we just surround it with a + "if false { }" test. + + :param name: the filter's name + """ + ifcontrol = get_command_instance("if") + falsecmd = get_command_instance("false", ifcontrol) + ifcontrol.check_next_arg("test", falsecmd) + for f in self.filters: + if f["name"] != name: + continue + ifcontrol.addchild(f["content"]) + f["content"] = ifcontrol + f["enabled"] = False + return True + return False + + def movefilter(self, name, direction): + """Moves the filter up or down + + :param name: the filter's name + :param direction: string "up" or "down" + """ + cpt = 0 + for f in self.filters: + if f["name"] == name: + if direction == "up": + if cpt == 0: + return False + self.filters.remove(f) + self.filters.insert(cpt - 1, f) + return True + if cpt == len(self.filters) - 1: + return False + self.filters.remove(f) + self.filters.insert(cpt + 1, f) + return True + cpt += 1 + return False # raise not found + + def dump(self): + """Dump this object + + Available for debugging purposes + """ + print "Dumping filters set %s\n" % self.name + + print "Dumping requirements" + self.__gen_require_command().dump() + print + + for f in self.filters: + print "Filter %s" % f["name"] + f["content"].dump() + + def tosieve(self, target=sys.stdout): + """Generate the sieve syntax corresponding to this filters set + + This method will usually be called when this filters set is + done. The default is to print the sieve syntax on the standard + output. You can pass an opened file pointer object if you want + to write the content elsewhere. + + :param target: file pointer where the sieve syntax will be printed + """ + self.__gen_require_command().tosieve(target=target) + target.write("\n") + for f in self.filters: + print >>target, "# Filter: %s" % f["name"] + f["content"].tosieve(target=target) + + +if __name__ == "__main__": + fs = FiltersSet("test") + + fs.addfilter("rule1", + [("Sender", ":is", "toto@toto.com"),], + [("fileinto", "Toto"),]) + fs.tosieve() + 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 + diff --git a/sievelib/parser.py b/sievelib/parser.py new file mode 100755 index 0000000..a3670d9 --- /dev/null +++ b/sievelib/parser.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python +# coding: utf-8 + +""" +This module provides a simple but functional parser for the SIEVE +language used to filter emails. + +This implementation is based on RFC 5228 (http://tools.ietf.org/html/rfc5228) + +""" +import re +import sys + +from commands import get_command_instance, UnknownCommand, BadArgument, BadValue + +class ParseError(Exception): + """Generic parsing error""" + def __init__(self, msg): + self.msg = msg + + def __str__(self): + return "parsing error: %s" % self.msg + +class Lexer(object): + """ + The lexical analysis part. + + This class provides a simple way to define tokens (with patterns) + to be detected. + + Patterns are provided into a list of 2-uple. Each 2-uple consists + of a token name and an associated pattern, example: + + [("left_bracket", r'\['),] + """ + def __init__(self, definitions): + self.definitions = definitions + parts = [] + for name, part in definitions: + parts.append("(?P<%s>%s)" % (name, part)) + self.regexpString = "|".join(parts) + self.regexp = re.compile(self.regexpString, re.MULTILINE) + self.wsregexp = re.compile(r'\s+', re.M) + + def curlineno(self): + """Return the current line number""" + return self.text[:self.pos].count('\n') + 1 + + def scan(self, text): + """Analyse some data + + Analyse the passed content. Each time a token is recognized, a + 2-uple containing its name and parsed value is raised (via + yield). + + On error, a ParseError exception is raised. + + :param text: a string containing the data to parse + """ + self.pos = 0 + self.text = text + while self.pos < len(text): + m = self.wsregexp.match(text, self.pos) + if m is not None: + self.pos = m.end() + continue + + m = self.regexp.match(text, self.pos) + if m is None: + lineno = self.curlineno() + raise ParseError("unknown token %s" % text[self.pos:]) + + self.pos = m.end() + yield (m.lastgroup, m.group(m.lastgroup)) + +class Parser(object): + """The grammatical analysis part. + + Here we define the SIEVE language tokens and grammar. This class + works with a Lexer object in order to check for grammar validity. + """ + lrules = [ + ("left_bracket", r'\['), + ("right_bracket", r'\]'), + ("left_parenthesis", r'\('), + ("right_parenthesis", r'\)'), + ("left_cbracket", r'{'), + ("right_cbracket", r'}'), + ("semicolon", r';'), + ("comma", r','), + ("hash_comment", r'#.*$'), + ("bracket_comment", r'/\*[\s\S]*?\*/'), + ("multiline", r'text:[^$]*[\r\n]+\.$'), + ("string", r'"([^"\\]|\\.)*"'), + ("identifier", r'[a-zA-Z_][\w]*'), + ("tag", r':[a-zA-Z_][\w]*'), + ("number", r'[0-9]+[KMGkmg]?'), + ] + + def __init__(self, debug=False): + self.debug = debug + self.lexer = Lexer(Parser.lrules) + + def __dprint(self, *msgs): + if not self.debug: + return + for m in msgs: + print m + + def __reset_parser(self): + """Reset parser's internal variables + + Restore the parser to an initial state. Useful when creating a + new parser or reusing an existing one. + """ + self.result = [] + self.hash_comments = [] + + self.__cstate = None + self.__curcommand = None + self.__curstringlist = None + self.__expected = None + self.__opened_blocks = 0 + + def __set_expected(self, *args, **kwargs): + """Set the next expected token. + + One or more tokens can be provided. (they will represent the + valid possibilities for the next token). + """ + self.__expected = args + + def __up(self, onlyrecord=False): + """Return to the current command's parent + + This method should be called each time a command is + complete. In case of a top level command (no parent), it is + recorded into a specific list for further usage. + + :param onlyrecord: tell to only record the new command into its parent. + """ + if self.__curcommand.must_follow is not None: + if not self.__curcommand.parent: + prevcmd = self.result[-1] if len(self.result) else None + else: + prevcmd = self.__curcommand.parent.children[-2] \ + if len(self.__curcommand.parent.children) >= 2 else None + if prevcmd is None or prevcmd.name not in self.__curcommand.must_follow: + raise ParseError("the %s command must follow an %s command" % \ + (self.__curcommand.name, + " or ".join(self.__curcommand.must_follow))) + + if not self.__curcommand.parent: + self.result += [self.__curcommand] + + if not onlyrecord: + self.__curcommand = self.__curcommand.parent + + def __check_command_completion(self, testsemicolon=True): + """Check for command(s) completion + + This function should be called each time a new argument is + seen by the parser in order to check a command is complete. As + not only one command can be ended when receiving a new + argument (nested commands case), we apply the same work to + parent commands. + + :param testsemicolon: if True, indicates that the next + expected token must be a semicolon (for commands that need one) + :return: True if command is + considered as complete, False otherwise. + """ + if not self.__curcommand.iscomplete(): + return True + + ctype = self.__curcommand.get_type() + if ctype == "action" or \ + (ctype == "control" and \ + not self.__curcommand.accept_children): + if testsemicolon: + self.__set_expected("semicolon") + return True + + while self.__curcommand.parent: + cmd = self.__curcommand + self.__curcommand = self.__curcommand.parent + if self.__curcommand.get_type() in ["control", "test"]: + if self.__curcommand.iscomplete(): + if self.__curcommand.get_type() == "control": + break + continue + if not self.__curcommand.check_next_arg("test", cmd, add=False): + return False + if not self.__curcommand.iscomplete(): + if self.__curcommand.variable_args_nb: + self.__set_expected("comma", "right_parenthesis") + break + + return True + + def __stringlist(self, ttype, tvalue): + """Specific method to parse the 'string-list' type + + Syntax: + string-list = "[" string *("," string) "]" / string + ; if there is only a single string, the brackets + ; are optional + """ + if ttype == "string": + self.__curstringlist += [tvalue] + self.__set_expected("comma", "right_bracket") + return True + if ttype == "comma": + self.__set_expected("string") + return True + if ttype == "right_bracket": + self.__curcommand.check_next_arg("stringlist", self.__curstringlist) + self.__cstate = self.__arguments + return self.__check_command_completion() + return False + + def __argument(self, ttype, tvalue): + """Argument parsing method + + This method acts as an entry point for 'argument' parsing. + + Syntax: + string-list / number / tag + + :param ttype: current token type + :param tvalue: current token value + :return: False if an error is encountered, True otherwise + """ + if ttype == "multiline": + return self.__curcommand.check_next_arg("string", tvalue) + + if ttype in ["number", "tag", "string"]: + return self.__curcommand.check_next_arg(ttype, tvalue) + + if ttype == "left_bracket": + self.__cstate = self.__stringlist + self.__curstringlist = [] + self.__set_expected("string") + return True + return False + + def __arguments(self, ttype, tvalue): + """Arguments parsing method + + Entry point for command arguments parsing. The parser must + call this method for each parsed command (either a control, + action or test). + + Syntax: + *argument [ test / test-list ] + + :param ttype: current token type + :param tvalue: current token value + :return: False if an error is encountered, True otherwise + """ + if ttype == "identifier": + test = get_command_instance(tvalue, self.__curcommand) + self.__curcommand.check_next_arg("test", test) + self.__expected = test.get_expected_first() + self.__curcommand = test + return self.__check_command_completion(testsemicolon=False) + + if ttype == "left_parenthesis": + self.__set_expected("identifier") + return True + + if ttype == "comma": + self.__set_expected("identifier") + return True + + if ttype == "right_parenthesis": + self.__up() + return True + + if self.__argument(ttype, tvalue): + return self.__check_command_completion() + + return False + + def __command(self, ttype, tvalue): + """Command parsing method + + Entry point for command parsing. Here is expected behaviour: + * Handle command beginning if detected, + * Call the appropriate sub-method (specified by __cstate) to + handle the body, + * Handle command ending or block opening if detected. + + Syntax: + identifier arguments (";" / block) + + :param ttype: current token type + :param tvalue: current token value + :return: False if an error is encountered, True otherwise + """ + if self.__cstate is None: + if ttype == "right_cbracket": + self.__up() + self.__opened_blocks -= 1 + self.__cstate = None + return True + + if ttype != "identifier": + return False + command = get_command_instance(tvalue, self.__curcommand) + if command.get_type() == "test": + raise ParseError("%s may not appear as a first command" % command.name) + if command.get_type() == "control" and command.accept_children \ + and command.has_arguments(): + self.__set_expected("identifier") + if self.__curcommand is not None: + if not self.__curcommand.addchild(command): + raise ParseError("%s unexpected after a %s" % \ + (tvalue, self.__curcommand.name)) + self.__curcommand = command + self.__cstate = self.__arguments + + return True + + if self.__cstate(ttype, tvalue): + return True + + if ttype == "left_cbracket": + self.__opened_blocks += 1 + self.__cstate = None + return True + + if ttype == "semicolon": + self.__cstate = None + if not self.__check_command_completion(testsemicolon=False): + return False + self.__curcommand.complete_cb() + self.__up() + return True + return False + + def parse(self, text): + """The parser entry point. + + Parse the provided text to check for its validity. + + On success, the parsing tree is available into the result + attribute. It is a list of sievecommands.Command objects (see + the module documentation for specific information). + + On error, an string containing the explicit reason is + available into the error attribute. + + :param text: a string containing the data to parse + :return: True on success (no error detected), False otherwise + """ + self.__reset_parser() + try: + for ttype, tvalue in self.lexer.scan(text): + if ttype == "hash_comment": + self.hash_comments += [tvalue] + continue + if ttype == "bracket_comment": + continue + if self.__expected is not None: + if not ttype in self.__expected: + if self.lexer.pos < len(text): + msg = "%s found while %s expected near '%s'" \ + % (ttype, "|".join(self.__expected), text[self.lexer.pos]) + else: + msg = "%s found while %s expected at end of file" \ + % (ttype, "|".join(self.__expected)) + raise ParseError(msg) + self.__expected = None + + if not self.__command(ttype, tvalue): + msg = "unexpected token '%s' found near '%s'" \ + % (tvalue, text[self.lexer.pos]) + raise ParseError(msg) + if self.__opened_blocks: + self.__set_expected("right_cbracket") + if self.__expected is not None: + raise ParseError("end of script reached while %s expected" % \ + "|".join(self.__expected)) + + except (ParseError, UnknownCommand, BadArgument, BadValue), e: + self.error = "line %d: %s" % (self.lexer.curlineno(), str(e)) + return False + return True + + def parse_file(self, name): + """Parse the content of a file. + + See 'parse' method for information. + + :param name: the pathname of the file to parse + :return: True on success (no error detected), False otherwise + """ + fp = open(name) + content = fp.read() + fp.close() + return self.parse(content) + + def dump(self, target=sys.stdout): + """Dump the parsing tree. + + This method displays the parsing tree on the standard output. + """ + for r in self.result: + r.dump(target=target) + +if __name__ == "__main__": + from optparse import OptionParser + + op = OptionParser() + op.usage = "%prog: [options] files" + op.add_option("-v", "--verbose", action="store_true", default=False, + help="Activate verbose mode") + op.add_option("-d", "--debug", action="store_true", default=False, + help="Activate debug traces") + options, args = op.parse_args() + + if not len(args): + print "Nothing to parse, exiting." + sys.exit(0) + + for a in args: + p = Parser(debug=options.debug) + print "Parsing file %s... " % a, + if p.parse_file(a): + print "OK" + if options.verbose: + p.dump() + continue + print "ERROR" + print p.error + + |