././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1607336641.782096 sievelib-1.2.1/0000775000372000037200000000000000000000000014160 5ustar00travistravis00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/.gitignore0000664000372000037200000000004200000000000016144 0ustar00travistravis00000000000000.*~$ *.pyc dist sievelib.egg-info ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/.travis.yml0000664000372000037200000000103400000000000016267 0ustar00travistravis00000000000000language: python cache: pip python: - '3.5' - '3.8' before_install: - pip install codecov nose install: - python setup.py -q install script: nosetests --with-coverage after_success: - codecov deploy: provider: pypi user: tonio password: secure: Mb1Xiif6MnUmEC6c0lUcW3BEqxvmBeh8V46BkMznX7FgqG1jUcBTtvLLub7Hzh31gvL7xltcUCS9AMhu3CnJrxSLxkyzthpWMp/kib00WM5qmrw9o8ZWeJm+wFMfFJchZ7Nx61PL/17D/Qjaf6lNLYQudXW8Z+hZ1CcQic3B3Kw= skip_cleanup: true distributions: sdist bdist_wheel on: tags: true python: '3.8' ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/COPYING0000664000372000037200000000207000000000000015212 0ustar00travistravis00000000000000Copyright (c) 2011-2015 Antoine Nguyen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/MANIFEST.in0000664000372000037200000000013000000000000015710 0ustar00travistravis00000000000000include README.rst COPYING requirements.txt include sievelib/tests/files/utf8_sieve.txt ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1607336641.782096 sievelib-1.2.1/PKG-INFO0000664000372000037200000001424100000000000015257 0ustar00travistravis00000000000000Metadata-Version: 1.1 Name: sievelib Version: 1.2.1 Summary: Client-side SIEVE library Home-page: https://github.com/tonioo/sievelib Author: Antoine Nguyen Author-email: tonio@ngyn.org License: MIT Description: sievelib ======== |travis| |codecov| |latest-version| Client-side Sieve and Managesieve library written in Python. * Sieve : An Email Filtering Language (`RFC 5228 `_) * ManageSieve : A Protocol for Remotely Managing Sieve Scripts (`RFC 5804 `_) Installation ------------ To install ``sievelib`` from PyPI:: pip install sievelib To install sievelib from git:: git clone git@github.com:tonioo/sievelib.git cd sievelib python ./setup.py install 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: * Copying Without Side Effects (`RFC 3894 `_) * Body (`RFC 5173 `_) * Vacation (`RFC 5230 `_) * Relational (`RFC 5231 `_) * Imap4flags (`RFC 5232 `_) The following extensions are partially supported: * Date and Index (`RFC 5260 `_) * Checking Mailbox Status and Accessing Mailbox Metadata (`RFC 5490 `_) Extending the parser ^^^^^^^^^^^^^^^^^^^^ It is possible to extend the parser by adding new supported commands. For example:: import sievelib class MyCommand(sievelib.commands.ActionCommand): args_definition = [ {"name": "testtag", "type": ["tag"], "write_tag": True, "values": [":testtag"], "extra_arg": {"type": "number", "required": False}, "required": False}, {"name": "recipients", "type": ["string", "stringlist"], "required": True} ] sievelib.commands.add_commands(MyCommand) 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 FiltersSet >>> fs = FiltersSet("test") >>> 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"; } >>> Additional documentation is available within 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 >>> Additional documentation is available with source code. .. |latest-version| image:: https://badge.fury.io/py/sievelib.svg :target: https://badge.fury.io/py/sievelib .. |travis| image:: https://travis-ci.org/tonioo/sievelib.png?branch=master :target: https://travis-ci.org/tonioo/sievelib .. |codecov| image:: http://codecov.io/github/tonioo/sievelib/coverage.svg?branch=master :target: http://codecov.io/github/tonioo/sievelib?branch=master 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 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/README.rst0000664000372000037200000001045400000000000015653 0ustar00travistravis00000000000000sievelib ======== |travis| |codecov| |latest-version| Client-side Sieve and Managesieve library written in Python. * Sieve : An Email Filtering Language (`RFC 5228 `_) * ManageSieve : A Protocol for Remotely Managing Sieve Scripts (`RFC 5804 `_) Installation ------------ To install ``sievelib`` from PyPI:: pip install sievelib To install sievelib from git:: git clone git@github.com:tonioo/sievelib.git cd sievelib python ./setup.py install 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: * Copying Without Side Effects (`RFC 3894 `_) * Body (`RFC 5173 `_) * Vacation (`RFC 5230 `_) * Relational (`RFC 5231 `_) * Imap4flags (`RFC 5232 `_) The following extensions are partially supported: * Date and Index (`RFC 5260 `_) * Checking Mailbox Status and Accessing Mailbox Metadata (`RFC 5490 `_) Extending the parser ^^^^^^^^^^^^^^^^^^^^ It is possible to extend the parser by adding new supported commands. For example:: import sievelib class MyCommand(sievelib.commands.ActionCommand): args_definition = [ {"name": "testtag", "type": ["tag"], "write_tag": True, "values": [":testtag"], "extra_arg": {"type": "number", "required": False}, "required": False}, {"name": "recipients", "type": ["string", "stringlist"], "required": True} ] sievelib.commands.add_commands(MyCommand) 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 FiltersSet >>> fs = FiltersSet("test") >>> 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"; } >>> Additional documentation is available within 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 >>> Additional documentation is available with source code. .. |latest-version| image:: https://badge.fury.io/py/sievelib.svg :target: https://badge.fury.io/py/sievelib .. |travis| image:: https://travis-ci.org/tonioo/sievelib.png?branch=master :target: https://travis-ci.org/tonioo/sievelib .. |codecov| image:: http://codecov.io/github/tonioo/sievelib/coverage.svg?branch=master :target: http://codecov.io/github/tonioo/sievelib?branch=master ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/requirements.txt0000664000372000037200000000023300000000000017442 0ustar00travistravis00000000000000# Requirements are listed in setup.py. The dot on the next line refers to # the current directory, instructing installers to use this package's setup.py . ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1607336641.782096 sievelib-1.2.1/setup.cfg0000664000372000037200000000010300000000000015773 0ustar00travistravis00000000000000[bdist_wheel] universal = 1 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/setup.py0000664000372000037200000000244700000000000015701 0ustar00travistravis00000000000000#!/usr/bin/env python """ A setuptools based setup module. See: https://packaging.python.org/en/latest/distributing.html """ import io from os import path from setuptools import setup, find_packages if __name__ == "__main__": HERE = path.abspath(path.dirname(__file__)) with io.open(path.join(HERE, "README.rst"), encoding="utf-8") as readme: LONG_DESCRIPTION = readme.read() setup( name="sievelib", packages=find_packages(), include_package_data=True, description="Client-side SIEVE library", author="Antoine Nguyen", author_email="tonio@ngyn.org", url="https://github.com/tonioo/sievelib", license="MIT", keywords=["sieve", "managesieve", "parser", "client"], install_requires=[], setup_requires=["setuptools_scm"], use_scm_version=True, 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=LONG_DESCRIPTION ) ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1607336641.778098 sievelib-1.2.1/sievelib/0000775000372000037200000000000000000000000015762 5ustar00travistravis00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/__init__.py0000664000372000037200000000032300000000000020071 0ustar00travistravis00000000000000"""sievelib.""" from pkg_resources import get_distribution, DistributionNotFound try: __version__ = get_distribution(__name__).version except DistributionNotFound: # package is not installed pass ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/commands.py0000664000372000037200000010217500000000000020143 0ustar00travistravis00000000000000""" 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 from collections.abc import Iterable from . import tools class CommandError(Exception): """Base command exception class.""" pass class UnknownCommand(CommandError): """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(CommandError): """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(CommandError): """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) class ExtensionNotLoaded(CommandError): """Raised when an extension is not loaded.""" def __init__(self, name): self.name = name def __str__(self): return "extension '{}' not loaded".format(self.name) # 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"], "extension_values": { ":count": "relational", ":value": "relational" }, "extra_arg": { "type": "string", "values": ['"gt"', '"ge"', '"lt"', '"le"', '"eq"', '"ne"'], "valid_for": [":count", ":value"] }, "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 unknown 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 non_deterministic_args = False accept_children = False must_follow = None extension = None def __init__(self, parent=None): self.parent = parent self.arguments = {} self.extra_arguments = {} # to store tag 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() self.hash_comments = [] 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 arg["name"] in self.arguments: continue target.write(" ") value = self.arguments[arg["name"]] atype = arg["type"] if "tag" in atype: target.write(value) if arg["name"] in self.extra_arguments: value = self.extra_arguments[arg["name"]] atype = arg["extra_arg"]["type"] target.write(" ") else: continue 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( "[{}]".format(", ".join( ['"%s"' % v.strip('"') for v in value]) ) ) continue if isinstance(value, Command): value.tosieve(indentlevel, target=target) continue if "string" in atype: target.write(value) if not value.startswith('"') and not value.startswith("["): target.write("\n") else: target.write(value) if not self.accept_children: if self.get_type() != "test": target.write(";\n") return if self.get_type() != "control": return target.write(" {\n") 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: target.write(text + "\n") 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 reassign_arguments(self): """Reassign arguments to proper slots. Should be called when parsing of commands with non deterministic arguments is considered done. """ raise NotImplementedError 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 arg["name"] in self.arguments: continue value = self.arguments[arg["name"]] atype = arg["type"] if "tag" in atype: self.__print(str(value), indentlevel, target=target) if arg["name"] in self.extra_arguments: value = self.extra_arguments[arg["name"]] atype = arg["extra_arg"]["type"] else: continue 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 walk(self): """Walk through commands.""" yield self if self.has_arguments(): for arg in self.args_definition: if not arg["name"] in self.arguments: continue value = self.arguments[arg["name"]] if type(value) == list: if self.__get_arg_type(arg["name"]) == ["testlist"]: for t in value: for node in t.walk(): yield node if isinstance(value, Command): for node in value.walk(): yield node for ch in self.children: for node in ch.walk(): yield node 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, atype=None, avalue=None): """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.get("required", False): self.required_args += 1 return ( (not self.curarg or "extra_arg" not in self.curarg or ("valid_for" in self.curarg["extra_arg"] and atype and atype in self.curarg["extra_arg"]["type"] and avalue not in self.curarg["extra_arg"]["valid_for"])) and (self.rargs_cnt == self.required_args) ) def get_type(self): """Return the command's type""" if self._type is None: raise NotImplementedError return self._type def __is_valid_value_for_arg(self, arg, value, check_extension=True): """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 :param check_extension: check if value requires an extension :return: True on succes, False otherwise """ if "values" not in arg and "extension_values" not in arg: return True if "values" in arg and value.lower() in arg["values"]: return True if "extension_values" in arg: extension = arg["extension_values"].get(value.lower()) if extension: condition = ( check_extension and extension not in RequireCommand.loaded_extensions ) if condition: raise ExtensionNotLoaded(extension) return True return False def __is_valid_type(self, typ, typlist): """ Check if type is valid based on input type list "string" is special because it can be used for stringlist :param typ: the type to check :param typlist: the list of type to check :return: True on success, False otherwise """ typ_is_str = typ == "string" str_list_in_typlist = "stringlist" in typlist return typ in typlist or (typ_is_str and str_list_in_typlist) def check_next_arg(self, atype, avalue, add=True, check_extension=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 not the required ones. A special handling is also done 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 :param check_extension: raise ExtensionNotLoaded if extension not loaded :return: True on success, False otherwise """ if not self.has_arguments(): return False if self.iscomplete(atype, avalue): return False if self.curarg is not None and "extra_arg" in self.curarg: condition = ( atype in self.curarg["extra_arg"]["type"] and ("values" not in self.curarg["extra_arg"] or avalue in self.curarg["extra_arg"]["values"]) ) if condition: if add: self.extra_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.get("required", False): if curarg["type"] == ["testlist"]: if atype != "test": failed = True elif add: if not curarg["name"] in self.arguments: self.arguments[curarg["name"]] = [] self.arguments[curarg["name"]] += [avalue] elif not self.__is_valid_type(atype, curarg["type"]) or \ not self.__is_valid_value_for_arg( curarg, avalue, check_extension): failed = True else: self.curarg = curarg self.rargs_cnt += 1 self.nextargpos = pos + 1 if add: self.arguments[curarg["name"]] = avalue break condition = ( atype in curarg["type"] and self.__is_valid_value_for_arg(curarg, avalue, check_extension) ) if condition: ext = curarg.get("extension") condition = ( check_extension and ext and ext not in RequireCommand.loaded_extensions) if condition: raise ExtensionNotLoaded(ext) condition = ( "extra_arg" in curarg and ("valid_for" not in curarg["extra_arg"] or avalue in curarg["extra_arg"]["valid_for"]) ) if condition: self.curarg = curarg 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 __contains__(self, name): """Check if argument is provided with command.""" return name in self.arguments 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 name not in self.arguments: 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"]) != list: exts = [self.arguments["capabilities"]] else: exts = self.arguments["capabilities"] for ext in exts: ext = ext.strip('"') if ext not in RequireCommand.loaded_extensions: RequireCommand.loaded_extensions += [ext] 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" def args_as_tuple(self): args = [] for name, value in list(self.arguments.items()): unquote = False for argdef in self.args_definition: if name == argdef["name"]: condition = ( "string" in argdef["type"] or "stringlist" in argdef["type"] ) if condition: unquote = True break if unquote: if "," in value: args += tools.to_list(value) else: args.append(value.strip('"')) continue args.append(value) return (self.name, ) + tuple(args) class StopCommand(ActionCommand): args_definition = [] class FileintoCommand(ActionCommand): extension = "fileinto" args_definition = [ {"name": "copy", "type": ["tag"], "values": [":copy"], "required": False, "extension": "copy"}, {"name": "create", "type": ["tag"], "values": [":create"], "required": False, "extension": "mailbox"}, {"name": "flags", "type": ["tag"], "values": [":flags"], "extra_arg": {"type": ["string", "stringlist"]}, "extension": "imap4flags"}, {"name": "mailbox", "type": ["string"], "required": True} ] class RedirectCommand(ActionCommand): args_definition = [ {"name": "copy", "type": ["tag"], "values": [":copy"], "required": False, "extension": "copy"}, {"name": "address", "type": ["string"], "required": True} ] class RejectCommand(ActionCommand): extension = "reject" args_definition = [ {"name": "text", "type": ["string"], "required": True} ] class KeepCommand(ActionCommand): args_definition = [ {"name": "flags", "type": ["tag"], "values": [":flags"], "extra_arg": {"type": ["string", "stringlist"]}, "extension": "imap4flags"}, ] class DiscardCommand(ActionCommand): args_definition = [] class SetflagCommand(ActionCommand): """imap4flags extension: setflag.""" args_definition = [ {"name": "variable-name", "type": ["string"], "required": False}, {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True} ] extension = "imap4flags" class AddflagCommand(ActionCommand): """imap4flags extension: addflag.""" args_definition = [ {"name": "variable-name", "type": ["string"], "required": False}, {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True} ] extension = "imap4flags" class RemoveflagCommand(ActionCommand): """imap4flags extension: removeflag.""" args_definition = [ {"name": "variable-name", "type": ["string"]}, {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True} ] extension = "imap4flags" 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} ] extension = "envelope" def args_as_tuple(self): """Return arguments as a list.""" result = ("envelope", self.arguments["match-type"]) value = self.arguments["header-list"] if isinstance(value, list): # FIXME value = "[{}]".format( ",".join('"{}"'.format(item) for item in value)) if value.startswith("["): result += (tools.to_list(value),) else: result += ([value.strip('"')],) value = self.arguments["key-list"] if isinstance(value, list): # FIXME value = "[{}]".format( ",".join('"{}"'.format(item) for item in value)) if value.startswith("["): result += (tools.to_list(value), ) else: result = result + ([value.strip('"')],) return result class ExistsCommand(TestCommand): args_definition = [ {"name": "header-names", "type": ["string", "stringlist"], "required": True} ] def args_as_tuple(self): """FIXME: en fonction de la manière dont la commande a été générée (factory ou parser), le type des arguments est différent : string quand ça vient de la factory ou type normal depuis le parser. Il faut uniformiser tout ça !! """ value = self.arguments["header-names"] if isinstance(value, list): value = "[{}]".format( ",".join('"{}"'.format(item) for item in value)) if not value.startswith("["): return ('exists', value.strip('"')) return ("exists", ) + tuple(tools.to_list(value)) 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} ] def args_as_tuple(self): """Return arguments as a list.""" if "," in self.arguments["header-names"]: result = tuple(tools.to_list(self.arguments["header-names"])) else: result = (self.arguments["header-names"].strip('"'),) result = result + (self.arguments["match-type"],) if "," in self.arguments["key-list"]: result = result + tuple( tools.to_list(self.arguments["key-list"], unquote=False)) else: result = result + (self.arguments["key-list"].strip('"'),) return result class BodyCommand(TestCommand): """Body extension. See https://tools.ietf.org/html/rfc5173. """ args_definition = [ comparator, match_type, {"name": "body-transform", "values": [":raw", ":content", ":text"], "extra_arg": {"type": "stringlist", "valid_for": [":content"]}, "type": ["tag"], "required": False}, {"name": "key-list", "type": ["string", "stringlist"], "required": True}, ] extension = "body" def args_as_tuple(self): """Return arguments as a list.""" result = ("body", ) result = result + ( self.arguments["body-transform"], self.arguments["match-type"]) value = self.arguments["key-list"] if isinstance(value, list): # FIXME value = "[{}]".format( ",".join('"{}"'.format(item) for item in value)) if value.startswith("["): result += tuple(tools.to_list(value)) else: result += (value.strip('"'),) return result 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}, ] def args_as_tuple(self): return ("size", self.arguments["comparator"], self.arguments["limit"]) class HasflagCommand(TestCommand): """imap4flags extension: hasflag.""" args_definition = [ comparator, match_type, {"name": "variable-list", "type": ["string", "stringlist"], "required": False}, {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True} ] extension = "imap4flags" non_deterministic_args = True def reassign_arguments(self): """Deal with optional stringlist before a required one.""" condition = ( "variable-list" in self.arguments and "list-of-flags" not in self.arguments ) if condition: self.arguments["list-of-flags"] = ( self.arguments.pop("variable-list")) self.rargs_cnt = 1 class DateCommand(TestCommand): """date command, part of the date extension. https://tools.ietf.org/html/rfc5260#section-4 """ extension = "date" args_definition = [ {"name": "zone", "type": ["tag"], "values": [":zone", ":originalzone"], "extra_arg": {"type": "string", "valid_for": [":zone"]}, "required": False}, comparator, match_type, {"name": "header-name", "type": ["string"], "required": True}, {"name": "date-part", "type": ["string"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True} ] class CurrentdateCommand(TestCommand): """currentdate command, part of the date extension. http://tools.ietf.org/html/rfc5260#section-5 """ extension = "date" args_definition = [ {"name": "zone", "type": ["tag"], "values": [":zone"], "extra_arg": {"type": "string"}, "required": False}, comparator, match_type, {"name": "date-part", "type": ["string"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True} ] def args_as_tuple(self): """Return arguments as a list.""" result = ("currentdate", ) result += ( ":zone", self.extra_arguments["zone"].strip('"'), self.arguments["match-type"], ) if self.arguments["match-type"] in [":count", ":value"]: result += (self.extra_arguments["match-type"].strip('"'), ) result += (self.arguments["date-part"].strip('"'), ) value = self.arguments["key-list"] if isinstance(value, list): # FIXME value = "[{}]".format( ",".join('"{}"'.format(item) for item in value)) if value.startswith("["): result = result + tuple(tools.to_list(value)) else: result = result + (value.strip('"'),) return result class VacationCommand(ActionCommand): args_definition = [ {"name": "subject", "type": ["tag"], "values": [":subject"], "extra_arg": {"type": "string"}, "required": False}, {"name": "days", "type": ["tag"], "values": [":days"], "extra_arg": {"type": "number"}, "required": False}, {"name": "from", "type": ["tag"], "values": [":from"], "extra_arg": {"type": "string"}, "required": False}, {"name": "addresses", "type": ["tag"], "values": [":addresses"], "extra_arg": {"type": ["string", "stringlist"]}, "required": False}, {"name": "handle", "type": ["tag"], "values": [":handle"], "extra_arg": {"type": "string"}, "required": False}, {"name": "mime", "type": ["tag"], "values": [":mime"], "required": False}, {"name": "reason", "type": ["string"], "required": True}, ] class SetCommand(ControlCommand): """set command, part of the variables extension http://tools.ietf.org/html/rfc5229 """ extension = "variables" args_definition = [ {"name": "startend", "type": ["string"], "required": True}, {"name": "date", "type": ["string"], "required": True} ] def add_commands(cmds): """ Adds one or more commands to the module namespace. Commands must end in "Command" to be added. Example (see tests/parser.py): sievelib.commands.add_commands(MytestCommand) :param cmds: a single Command Object or list of Command Objects """ if not isinstance(cmds, Iterable): cmds = [cmds] for command in cmds: if command.__name__.endswith("Command"): globals()[command.__name__] = command 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() gl = globals() condition = ( cname not in gl or (checkexists and gl[cname].extension and gl[cname].extension not in RequireCommand.loaded_extensions) ) if condition: raise UnknownCommand(name) return gl[cname](parent) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/digest_md5.py0000664000372000037200000000470100000000000020362 0ustar00travistravis00000000000000""" 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(r'(\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, authz_id=''): 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) if authz_id: if type(authz_id) is unicode: authz_id = authz_id.encode("utf-8") dgres += ',authzid="%s"' % authz_id 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)) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/factory.py0000664000372000037200000004763000000000000020015 0ustar00travistravis00000000000000""" 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 io import sys from sievelib import commands class FiltersSet(object): """A set of filters.""" def __init__(self, name, filter_name_pretext=u"# Filter: ", filter_desc_pretext=u"# Description: "): """Represents a set of one or more filters :param name: the filterset's name :param filter_name_pretext: the text that is used to mark a filter name (as comment preceding the filter) :param filter_desc_pretext: the text that is used to mark a filter description """ self.name = name self.filter_name_pretext = filter_name_pretext self.filter_desc_pretext = filter_desc_pretext self.requires = [] self.filters = [] def __str__(self): target = io.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 fcontent: the filter's name """ if not isinstance(fcontent, commands.IfCommand): return False if not isinstance(fcontent["test"], commands.FalseCommand): return False return True def from_parser_result(self, parser): cpt = 1 for f in parser.result: if isinstance(f, commands.RequireCommand): if type(f.arguments["capabilities"]) == list: [self.require(c) for c in f.arguments["capabilities"]] else: self.require(f.arguments["capabilities"]) continue name = "Unnamed rule %d" % cpt description = "" for comment in f.hash_comments: if isinstance(comment, bytes): comment = comment.decode("utf-8") if comment.startswith(self.filter_name_pretext): name = comment.replace(self.filter_name_pretext, "") if comment.startswith(self.filter_desc_pretext): description = comment.replace(self.filter_desc_pretext, "") self.filters += [{"name": name, "description": description, "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 name not in self.requires: self.requires += [name] def check_if_arg_is_extension(self, arg): """Include extension if arg requires one.""" args_using_extensions = { ":copy": "copy" } if arg in args_using_extensions: self.require(args_using_extensions[arg]) def __gen_require_command(self): """Internal method to create a RequireCommand based on requirements Called just before this object is going to be dumped. """ if not len(self.requires): return None reqcmd = commands.get_command_instance("require") reqcmd.check_next_arg("stringlist", self.requires) return reqcmd def __quote_if_necessary(self, value): """Add double quotes to the given string if necessary :param value: the string to check :return: the string between quotes """ if not value.startswith(('"', "'")): return '"%s"' % value return value def __build_condition(self, condition, parent, tag=None): """Translate a condition to a valid sievelib Command. :param list condition: condition's definition :param ``Command`` parent: the parent :param str tag: tag to use instead of the one included into :keyword:`condition` :rtype: Command :return: the generated command """ if tag is None: tag = condition[1] cmd = commands.get_command_instance("header", parent) cmd.check_next_arg("tag", tag) cmd.check_next_arg("string", self.__quote_if_necessary(condition[0])) cmd.check_next_arg("string", self.__quote_if_necessary(condition[2])) return cmd 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 = commands.get_command_instance("if") mtypeobj = commands.get_command_instance(matchtype, ifcontrol) for c in conditions: if c[0].startswith("not"): negate = True cname = c[0].replace("not", "", 1) else: negate = False cname = c[0] if cname in ("true", "false"): cmd = commands.get_command_instance(c[0], ifcontrol) elif cname == "size": cmd = commands.get_command_instance("size", ifcontrol) cmd.check_next_arg("tag", c[1]) cmd.check_next_arg("number", c[2]) elif cname == "exists": cmd = commands.get_command_instance("exists", ifcontrol) cmd.check_next_arg( "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[1:])) ) elif cname == "envelope": cmd = commands.get_command_instance( "envelope", ifcontrol, False) self.require("envelope") if c[1].startswith(":not"): comp_tag = c[1].replace("not", "") negate = True else: comp_tag = c[1] cmd.check_next_arg("tag", comp_tag) cmd.check_next_arg( "stringlist", "[{}]".format(",".join('"{}"'.format(val) for val in c[2])) ) cmd.check_next_arg( "stringlist", "[{}]".format(",".join('"{}"'.format(val) for val in c[3])) ) elif cname == "body": cmd = commands.get_command_instance("body", ifcontrol, False) self.require(cmd.extension) cmd.check_next_arg("tag", c[1]) if c[2].startswith(":not"): comp_tag = c[2].replace("not", "") negate = True else: comp_tag = c[2] cmd.check_next_arg("tag", comp_tag) cmd.check_next_arg( "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[3:])) ) elif cname == "currentdate": cmd = commands.get_command_instance( "currentdate", ifcontrol, False) self.require(cmd.extension) cmd.check_next_arg("tag", c[1]) cmd.check_next_arg("string", self.__quote_if_necessary(c[2])) if c[3].startswith(":not"): comp_tag = c[3].replace("not", "") negate = True else: comp_tag = c[3] cmd.check_next_arg("tag", comp_tag, check_extension=False) next_arg_pos = 4 if comp_tag == ":value": self.require("relational") cmd.check_next_arg( "string", self.__quote_if_necessary(c[next_arg_pos])) next_arg_pos += 1 cmd.check_next_arg( "string", self.__quote_if_necessary(c[next_arg_pos])) next_arg_pos += 1 cmd.check_next_arg( "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[next_arg_pos:])) ) else: # header command fallback if c[1].startswith(':not'): cmd = self.__build_condition( c, ifcontrol, c[1].replace("not", "", 1)) negate = True else: cmd = self.__build_condition(c, ifcontrol) if negate: not_cmd = commands.get_command_instance("not", ifcontrol) not_cmd.check_next_arg("test", cmd) cmd = not_cmd mtypeobj.check_next_arg("test", cmd) ifcontrol.check_next_arg("test", mtypeobj) for actdef in actions: action = commands.get_command_instance(actdef[0], ifcontrol, False) if action.extension is not None: self.require(action.extension) for arg in actdef[1:]: self.check_if_arg_is_extension(arg) if arg.startswith(":"): atype = "tag" else: atype = "string" arg = self.__quote_if_necessary(arg) action.check_next_arg(atype, arg, check_extension=False) ifcontrol.addchild(action) return ifcontrol def _unicode_filter_name(self, name): """Convert name to unicode if necessary.""" return ( name.decode("utf-8") if isinstance(name, bytes) else name ) 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": self._unicode_filter_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 order between filters. :param oldname: the filter's current name :param newname: the filter's new name :param conditions: the list of conditions :param actions: the list of actions :param matchtype: "anyof" or "allof" """ oldname = self._unicode_filter_name(oldname) newname = self._unicode_filter_name(newname) 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 replacefilter( self, oldname, sieve_filter, newname=None, description=None): """replace a specific sieve_filter Instead of removing and re-creating the sieve_filter, we update the content in order to keep the original order between filters. :param oldname: the sieve_filter's current name :param newname: the sieve_filter's new name :param sieve_filter: the sieve_filter object as get from FiltersSet.getfilter() """ oldname = self._unicode_filter_name(oldname) newname = self._unicode_filter_name(newname) if newname is None: newname = oldname for f in self.filters: if f["name"] == oldname: f["name"] = newname f["content"] = sieve_filter if description is not None: f['description'] = description 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 """ name = self._unicode_filter_name(name) for f in self.filters: if f["name"] == name: if not f["enabled"]: return f["content"].children[0] return f["content"] return None def get_filter_matchtype(self, name): """Retrieve matchtype of the given filter.""" flt = self.getfilter(name) if not flt: return None for node in flt.walk(): if isinstance(node, (commands.AllofCommand, commands.AnyofCommand)): return node.__class__.__name__.lower().replace("command", "") return None def get_filter_conditions(self, name): """Retrieve conditions of the given filter.""" flt = self.getfilter(name) if not flt: return None conditions = [] negate = False for node in flt.walk(): if isinstance(node, commands.NotCommand): negate = True elif isinstance(node, (commands.HeaderCommand, commands.SizeCommand, commands.ExistsCommand, commands.BodyCommand, commands.EnvelopeCommand, commands.CurrentdateCommand)): args = node.args_as_tuple() if negate: if node.name in ["header", "envelope"]: nargs = ( args[0], ":not{}".format(args[1][1:]) ) if len(args) > 3: nargs += (args[2:]) else: nargs += (args[2],) args = nargs elif node.name == "body": args = ( args[:2] + (":not{}".format(args[2][1:]),) + args[3:] ) elif node.name == "currentdate": args = ( args[:3] + (":not{}".format(args[3][1:]),) + args[4:] ) elif node.name == "exists": args = ("not{}".format(args[0]),) + args[1:] negate = False conditions.append(args) return conditions def get_filter_actions(self, name): """Retrieve actions of the given filter.""" flt = self.getfilter(name) if not flt: return None actions = [] for node in flt.walk(): if isinstance(node, commands.ActionCommand): actions.append(node.args_as_tuple()) return actions def removefilter(self, name): """Remove a specific filter :param name: the filter's name """ name = self._unicode_filter_name(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 """ name = self._unicode_filter_name(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 """ name = self._unicode_filter_name(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 :return: True if filter was disabled, False otherwise """ name = self._unicode_filter_name(name) ifcontrol = commands.get_command_instance("if") falsecmd = commands.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" """ name = self._unicode_filter_name(name) 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) cmd = self.__gen_require_command() if cmd: print("Dumping requirements") cmd.dump() print for f in self.filters: print("Filter Name: %s" % f["name"]) print("Filter Description: %s" % f["description"]) 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 """ cmd = self.__gen_require_command() if cmd: cmd.tosieve(target=target) target.write(u"\n") for f in self.filters: target.write("{}{}\n".format(self.filter_name_pretext, f["name"])) if "description" in f and f["description"]: target.write(u"{}{}\n".format( self.filter_desc_pretext, f["description"])) f["content"].tosieve(target=target) if __name__ == "__main__": fs = FiltersSet("test") fs.addfilter("rule1", [("Sender", ":is", "toto@toto.com"), ], [("fileinto", "Toto"), ]) fs.tosieve() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/managesieve.py0000664000372000037200000005452500000000000020633 0ustar00travistravis00000000000000""" 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 RFC 5804. """ import base64 import re import socket import ssl from .digest_md5 import DigestMD5 from . import tools CRLF = b"\r\n" KNOWN_CAPABILITIES = [u"IMPLEMENTATION", u"SASL", u"SIEVE", u"STARTTLS", u"NOTIFY", u"LANGUAGE", u"VERSION"] SUPPORTED_AUTH_MECHS = [u"DIGEST-MD5", u"PLAIN", u"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=4190, debug=False): self.srvaddr = srvaddr self.srvport = srvport self.__debug = debug self.sock = None self.__read_buffer = b"" self.authenticated = False self.errcode = None self.__capabilities = {} self.__respcode_expr = re.compile(br"(OK|NO|BYE)\s*(.+)?") self.__error_expr = re.compile(br'(\([\w/-]+\))?\s*(".+")') self.__size_expr = re.compile(br"\{(\d+)\+?\}") self.__active_expr = re.compile(br"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 = b"" 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): raise Error("Failed to read %d bytes from the server" % size) self.__dprint(buf) 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 = b"" while True: try: pos = self.__read_buffer.index(CRLF) ret = self.__read_buffer[:pos] self.__read_buffer = self.__read_buffer[pos + len(CRLF):] break except ValueError: pass try: nval = self.sock.recv(self.read_size) self.__dprint(nval) 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) == b"BYE": raise Error("Connection closed by server") if m.group(1) == b"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 = (b"", None, None) cpt = 0 while True: try: line = self.__read_line() except Response as inst: code = inst.code data = inst.data break except Literal as inst: resp += self.__read_block(inst.value) if not resp.endswith(CRLF): resp += self.__read_line() + CRLF 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 isinstance(a, bytes): if self.__size_expr.match(a): ret += [a] else: ret += [b'"' + a + b'"'] continue ret += [bytes(str(a).encode("utf-8"))] return ret def __prepare_content(self, content): """Format script content before sending it. Script length must be inserted before the content, enclosed in curly braces, separated by CRLF. :param content: script content as str or bytes :return: transformed script as bytes """ if isinstance(content, str): content = content.encode('utf-8') return b"{%d+}%s%s" % (len(content), CRLF, content) def __send_command( self, name, args=None, withcontent=False, extralines=None, 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.encode("utf-8") if args: tosend += b" " + b" ".join(self.__prepare_args(args)) self.__dprint(b"Command: " + tosend) self.sock.sendall(tosend + CRLF) if extralines: for l in extralines: self.sock.sendall(l + CRLF) code, data, content = self.__read_response(nblines) if isinstance(code, bytes): code = code.decode("utf-8") if isinstance(data, bytes): data = data.decode("utf-8") 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(): parts = l.split(None, 1) cname = parts[0].strip(b'"').decode("utf-8") if cname not in KNOWN_CAPABILITIES: continue self.__capabilities[cname] = ( parts[1].strip(b'"').decode("utf-8") 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 = b"" 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(b"()") else: self.errcode = b"" self.errmsg = m.group(2).strip(b'"') def _plain_authentication(self, login, password, authz_id=b""): """SASL PLAIN authentication :param login: username :param password: clear password :return: True on success, False otherwise. """ if isinstance(login, str): login = login.encode("utf-8") if isinstance(password, str): password = password.encode("utf-8") params = base64.b64encode(b'\0'.join([authz_id, login, password])) code, data = self.__send_command("AUTHENTICATE", [b"PLAIN", params]) if code == "OK": return True return False def _login_authentication(self, login, password, authz_id=""): """SASL LOGIN authentication :param login: username :param password: clear password :return: True on success, False otherwise. """ extralines = [b'"%s"' % base64.b64encode(login.encode("utf-8")), b'"%s"' % base64.b64encode(password.encode("utf-8"))] code, data = self.__send_command("AUTHENTICATE", [b"LOGIN"], extralines=extralines) if code == "OK": return True return False def _digest_md5_authentication(self, login, password, authz_id=""): """SASL DIGEST-MD5 authentication :param login: username :param password: clear password :return: True on success, False otherwise. """ code, data, challenge = \ self.__send_command("AUTHENTICATE", [b"DIGEST-MD5"], withcontent=True, nblines=1) dmd5 = DigestMD5(challenge, "sieve/%s" % self.srvaddr) code, data, challenge = self.__send_command( '"%s"' % dmd5.response(login, password, authz_id), withcontent=True, nblines=1 ) if not 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, authz_id=b"", 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 authz_id: authorization ID :param authmech: prefered authentication mechanism :return: True on success, False otherwise """ if "SASL" not in self.__capabilities: 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 mech not in srv_mechanisms: continue mech = mech.lower().replace("-", "_") auth_method = getattr(self, "_%s_authentication" % mech) if auth_method(login, password, authz_id): self.authenticated = True return True return False self.errmsg = b"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 as 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 "STARTTLS" in self.__capabilities 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 isinstance(self.__capabilities["SIEVE"], str): self.__capabilities["SIEVE"] = self.__capabilities["SIEVE"].split() return self.__capabilities["SIEVE"] def connect( self, login, password, authz_id=b"", 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.create_connection((self.srvaddr, self.srvport)) self.sock.settimeout(Client.read_timeout) except socket.error as 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, authz_id, 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.encode("utf-8"), 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(br'"([^"]+)"\s*(.+)', l) if m is None: ret += [l.strip(b'"').decode("utf-8")] continue script = m.group(1).decode("utf-8") if self.__active_expr.match(m.group(2)): active_script = script continue ret += [script] 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.encode("utf-8")], withcontent=True) if code == "OK": lines = content.splitlines() if self.__size_expr.match(lines[0]) is not None: lines = lines[1:] return u"\n".join([line.decode("utf-8") for line in 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 """ content = self.__prepare_content(content) code, data = ( self.__send_command("PUTSCRIPT", [name.encode("utf-8"), 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.encode("utf-8")]) 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 "VERSION" in self.__capabilities: code, data = self.__send_command( "RENAMESCRIPT", [oldname.encode("utf-8"), newname.encode("utf-8")]) if code == "OK": return True return False (active_script, scripts) = self.listscripts() condition = ( oldname != active_script and (scripts is None or oldname not in scripts) ) if condition: self.errmsg = b"Old script does not exist" return False if newname in scripts: self.errmsg = b"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.encode("utf-8")]) if code == "OK": return True return False @authentication_required def checkscript(self, content): """Check whether a script is valid See MANAGESIEVE specifications, section 2.12 :param name: script's content :rtype: boolean """ if "VERSION" not in self.__capabilities: raise NotImplementedError( "server does not support CHECKSCRIPT command") content = self.__prepare_content(content) code, data = self.__send_command("CHECKSCRIPT", [content]) if code == "OK": return True return False ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/parser.py0000775000372000037200000004504000000000000017636 0ustar00travistravis00000000000000#!/usr/bin/env python """ 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 sievelib.commands import ( get_command_instance, CommandError, RequireCommand) 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: [(b"left_bracket", br'\['),] """ def __init__(self, definitions): self.definitions = definitions parts = [] for name, part in definitions: param = b"(?P<%s>%s)" % (name, part) parts.append(param) self.regexpString = b"|".join(parts) self.regexp = re.compile(self.regexpString, re.MULTILINE) self.wsregexp = re.compile(br'\s+', re.M) def curlineno(self): """Return the current line number""" return self.text[:self.pos].count(b'\n') + 1 def curcolno(self): """Return the current column number""" return self.pos - self.text.rfind(b'\n', 0, self.pos) 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 binary 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: token = text[self.pos:] m = self.wsregexp.search(token) if m is not None: token = token[:m.start()] raise ParseError("unknown token %s" % token) yield (m.lastgroup, m.group(m.lastgroup)) self.pos += len(m.group(0)) 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 = [ (b"left_bracket", br'\['), (b"right_bracket", br'\]'), (b"left_parenthesis", br'\('), (b"right_parenthesis", br'\)'), (b"left_cbracket", br'{'), (b"right_cbracket", br'}'), (b"semicolon", br';'), (b"comma", br','), (b"hash_comment", br'#.*$'), (b"bracket_comment", br'/\*[\s\S]*?\*/'), (b"multiline", br'text:[^$]*?[\r\n]+\.$'), (b"string", br'"([^"\\]|\\.)*"'), (b"identifier", br'[a-zA-Z_][\w]*'), (b"tag", br':[a-zA-Z_][\w]*'), (b"number", br'[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.__expected_brackets = [] RequireCommand.loaded_extensions = [] 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 __push_expected_bracket(self, ttype, tvalue): """Append a new expected bracket. Next time a bracket is closed, it must match the one provided here. """ self.__expected_brackets.append((ttype, tvalue)) def __pop_expected_bracket(self, ttype, tvalue): """Drop the last expected bracket. If the given bracket doesn't match the dropped expected bracket, or if no bracket is expected at all, a ParseError will be raised. """ try: etype, evalue = self.__expected_brackets.pop() except IndexError: raise ParseError("unexpected closing bracket %s (none opened)" % (tvalue,)) if ttype != etype: raise ParseError("unexpected closing bracket %s (expected %s)" % (tvalue, evalue)) 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: # collect current amount of hash comments for later # parsing into names and desciptions self.__curcommand.hash_comments = self.hash_comments self.hash_comments = [] self.result += [self.__curcommand] if onlyrecord: # We are done return while self.__curcommand: self.__curcommand = self.__curcommand.parent if not self.__curcommand: break # Make sure to detect all done tests (including 'not' ones). condition = ( self.__curcommand.get_type() == "test" and self.__curcommand.iscomplete() ) if condition: continue # If we are on a control accepting a test list, next token # must be a comma or a right parenthesis. condition = ( self.__curcommand.get_type() == "test" and self.__curcommand.variable_args_nb ) if condition: self.__set_expected("comma", "right_parenthesis") break 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() condition = ( ctype == "action" or (ctype == "control" and not self.__curcommand.accept_children) ) if condition: 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": self.__set_expected("left_cbracket") 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.decode("utf-8")] self.__set_expected("comma", "right_bracket") return True if ttype == "comma": self.__set_expected("string") return True if ttype == "right_bracket": self.__pop_expected_bracket(ttype, tvalue) 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 in ["multiline", "string"]: return self.__curcommand.check_next_arg("string", tvalue.decode("utf-8")) if ttype in ["number", "tag"]: return self.__curcommand.check_next_arg(ttype, tvalue.decode("ascii")) if ttype == "left_bracket": self.__push_expected_bracket("right_bracket", b'}') self.__cstate = self.__stringlist self.__curstringlist = [] self.__set_expected("string") return True condition = ( ttype in ["left_cbracket", "comma"] and self.__curcommand.non_deterministic_args ) if condition: self.__curcommand.reassign_arguments() # rewind lexer self.lexer.pos -= 1 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.decode("ascii"), self.__curcommand) if test.get_type() != "test": raise ParseError( "Expected test command, '{}' found instead" .format(test.name) ) 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.__push_expected_bracket("right_parenthesis", b')') self.__set_expected("identifier") return True if ttype == "comma": self.__set_expected("identifier") return True if ttype == "right_parenthesis": self.__pop_expected_bracket(ttype, tvalue) self.__up() return True if self.__argument(ttype, tvalue): return self.__check_command_completion(testsemicolon=False) 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.__pop_expected_bracket(ttype, tvalue) self.__up() self.__cstate = None return True if ttype != "identifier": return False command = get_command_instance( tvalue.decode("ascii"), 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.__push_expected_bracket("right_cbracket", b'}') 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 """ if isinstance(text, str): text = text.encode("utf-8") self.__reset_parser() try: tvalue = '' for ttype, tvalue in self.lexer.scan(text): if ttype == "hash_comment": self.hash_comments += [tvalue.strip()] continue if ttype == "bracket_comment": continue if self.__expected is not None: if ttype not in self.__expected: if self.lexer.pos < len(text) + len(tvalue): msg = ( "{} found while {} expected near '{}'" .format(ttype, "|".join(self.__expected), text.decode()[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.decode(), text.decode()[self.lexer.pos]) ) raise ParseError(msg) if self.__expected_brackets: self.__set_expected(self.__expected_brackets[-1][0]) if self.__expected is not None: raise ParseError("end of script reached while %s expected" % "|".join(self.__expected)) except (ParseError, CommandError) as e: self.error_pos = (self.lexer.curlineno(), self.lexer.curcolno(), len(tvalue)) self.error = "line %d: %s" % (self.error_pos[0], 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 """ with open(name, "rb") as fp: return self.parse(fp.read()) 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") op.add_option("--tosieve", action="store_true", help="Print parser results using sieve") 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, end=' ') if p.parse_file(a): print("OK") if options.verbose: p.dump() if options.tosieve: for r in p.result: r.tosieve() continue print("ERROR") print(p.error) ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1607336641.778098 sievelib-1.2.1/sievelib/tests/0000775000372000037200000000000000000000000017124 5ustar00travistravis00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/tests/__init__.py0000664000372000037200000000000000000000000021223 0ustar00travistravis00000000000000././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1607336641.782096 sievelib-1.2.1/sievelib/tests/files/0000775000372000037200000000000000000000000020226 5ustar00travistravis00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/tests/files/utf8_sieve.txt0000664000372000037200000000026600000000000023054 0ustar00travistravis00000000000000require ["fileinto", "reject"]; # Filter: UTF8 Test Filter äöüß 汉语/漢語 Hànyǔ if allof (header :contains ["Subject"] ["€ 300"]) { fileinto "Spam"; stop; } ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/tests/test_factory.py0000664000372000037200000003024000000000000022203 0ustar00travistravis00000000000000import unittest import io from sievelib.factory import FiltersSet from .. import parser class FactoryTestCase(unittest.TestCase): def setUp(self): self.fs = FiltersSet("test") def test_get_filter_conditions(self): """Test get_filter_conditions method.""" orig_conditions = [('Sender', ":is", 'toto@toto.com')] self.fs.addfilter( "ruleX", orig_conditions, [("fileinto", ":copy", "Toto"), ]) conditions = self.fs.get_filter_conditions("ruleX") self.assertEqual(orig_conditions, conditions) orig_conditions = [("exists", "list-help", "list-unsubscribe", "list-subscribe", "list-owner")] self.fs.addfilter( "ruleY", orig_conditions, [("fileinto", 'List')] ) conditions = self.fs.get_filter_conditions("ruleY") self.assertEqual(orig_conditions, conditions) orig_conditions = [('Sender', ":notis", 'toto@toto.com')] self.fs.addfilter( "ruleZ", orig_conditions, [("fileinto", ":copy", "Toto"), ]) conditions = self.fs.get_filter_conditions("ruleZ") self.assertEqual(orig_conditions, conditions) orig_conditions = [("notexists", "list-help", "list-unsubscribe", "list-subscribe", "list-owner")] self.fs.addfilter( "ruleA", orig_conditions, [("fileinto", 'List')] ) conditions = self.fs.get_filter_conditions("ruleA") self.assertEqual(orig_conditions, conditions) orig_conditions = [("envelope", ":is", ["From"], ["hello"])] self.fs.addfilter( "ruleB", orig_conditions, [("fileinto", "INBOX")] ) conditions = self.fs.get_filter_conditions("ruleB") self.assertEqual(orig_conditions, conditions) orig_conditions = [("body", ":raw", ":notcontains", "matteo")] self.fs.addfilter( "ruleC", orig_conditions, [("fileinto", "INBOX")] ) conditions = self.fs.get_filter_conditions("ruleC") self.assertEqual(orig_conditions, conditions) orig_conditions = [( "currentdate", ":zone", "+0100", ":notis", "date", "2019-02-26" )] self.fs.addfilter( "ruleD", orig_conditions, [("fileinto", "INBOX")] ) conditions = self.fs.get_filter_conditions("ruleD") self.assertEqual(orig_conditions, conditions) orig_conditions = [( "currentdate", ":zone", "+0100", ":value", "gt", "date", "2019-02-26" )] self.fs.addfilter( "ruleE", orig_conditions, [("fileinto", "INBOX")] ) conditions = self.fs.get_filter_conditions("ruleE") self.assertEqual(orig_conditions, conditions) def test_get_filter_conditions_from_parser_result(self): res = """require ["fileinto"]; # rule:[test] if anyof (exists ["Subject"]) { fileinto "INBOX"; } """ p = parser.Parser() p.parse(res) fs = FiltersSet("test", '# rule:') fs.from_parser_result(p) c = fs.get_filter_conditions('[test]') self.assertEqual(c, [("exists", "Subject")]) res = """require ["date", "fileinto"]; # rule:aaa if anyof (currentdate :zone "+0100" :is "date" ["2019-03-27"]) { fileinto "INBOX"; } """ p = parser.Parser() p.parse(res) fs = FiltersSet("aaa", "# rule:") fs.from_parser_result(p) c = fs.get_filter_conditions('aaa') self.assertEqual( c, [('currentdate', ':zone', '+0100', ':is', 'date', '2019-03-27')] ) res = """require ["envelope", "fileinto"]; # rule:[aaa] if anyof (envelope :contains ["To"] ["hello@world.it"]) { fileinto "INBOX"; } """ p = parser.Parser() p.parse(res) fs = FiltersSet("aaa", "# rule:") fs.from_parser_result(p) c = fs.get_filter_conditions('[aaa]') self.assertEqual( c, [('envelope', ':contains', ['To'], ['hello@world.it'])] ) def test_get_filter_matchtype(self): """Test get_filter_matchtype method.""" self.fs.addfilter( "ruleX", [('Sender', ":is", 'toto@toto.com'), ], [("fileinto", ":copy", "Toto"), ]) match_type = self.fs.get_filter_matchtype("ruleX") self.assertEqual(match_type, "anyof") def test_get_filter_actions(self): """Test get_filter_actions method.""" self.fs.addfilter( "ruleX", [('Sender', ":is", 'toto@toto.com'), ], [("fileinto", ":copy", "Toto"), ]) actions = self.fs.get_filter_actions("ruleX") self.assertIn("fileinto", actions[0]) self.assertIn(":copy", actions[0]) self.assertIn("Toto", actions[0]) self.fs.addfilter( "ruleY", [("Subject", ":contains", "aaa")], [("stop",)] ) actions = self.fs.get_filter_actions("ruleY") self.assertIn("stop", actions[0]) def test_add_header_filter(self): output = io.StringIO() self.fs.addfilter( "rule1", [('Sender', ":is", 'toto@toto.com'), ], [("fileinto", ":copy", "Toto"), ]) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual(output.getvalue(), """require ["fileinto", "copy"]; # Filter: rule1 if anyof (header :is "Sender" "toto@toto.com") { fileinto :copy "Toto"; } """) output.close() def test_use_action_with_tag(self): output = io.StringIO() self.fs.addfilter( "rule1", [('Sender', ":is", 'toto@toto.com'), ], [("redirect", ":copy", "toto@titi.com"), ]) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual(output.getvalue(), """require ["copy"]; # Filter: rule1 if anyof (header :is "Sender" "toto@toto.com") { redirect :copy "toto@titi.com"; } """) output.close() def test_add_header_filter_with_not(self): output = io.StringIO() self.fs.addfilter( "rule1", [('Sender', ":notcontains", 'toto@toto.com')], [("fileinto", 'Toto')]) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual(output.getvalue(), """require ["fileinto"]; # Filter: rule1 if anyof (not header :contains "Sender" "toto@toto.com") { fileinto "Toto"; } """) def test_add_exists_filter(self): output = io.StringIO() self.fs.addfilter( "rule1", [('exists', "list-help", "list-unsubscribe", "list-subscribe", "list-owner")], [("fileinto", 'Toto')] ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual(output.getvalue(), """require ["fileinto"]; # Filter: rule1 if anyof (exists ["list-help","list-unsubscribe","list-subscribe","list-owner"]) { fileinto "Toto"; } """) def test_add_exists_filter_with_not(self): output = io.StringIO() self.fs.addfilter( "rule1", [('notexists', "list-help", "list-unsubscribe", "list-subscribe", "list-owner")], [("fileinto", 'Toto')] ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual(output.getvalue(), """require ["fileinto"]; # Filter: rule1 if anyof (not exists ["list-help","list-unsubscribe","list-subscribe","list-owner"]) { fileinto "Toto"; } """) def test_add_size_filter(self): output = io.StringIO() self.fs.addfilter( "rule1", [('size', ":over", "100k")], [("fileinto", 'Totoéé')] ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual(output.getvalue(), """require ["fileinto"]; # Filter: rule1 if anyof (size :over 100k) { fileinto "Totoéé"; } """) def test_remove_filter(self): self.fs.addfilter("rule1", [('Sender', ":is", 'toto@toto.com')], [("fileinto", 'Toto')]) self.assertIsNot(self.fs.getfilter("rule1"), None) self.assertEqual(self.fs.removefilter("rule1"), True) self.assertIs(self.fs.getfilter("rule1"), None) def test_disablefilter(self): """ FIXME: Extra spaces are written between if and anyof, why?! """ self.fs.addfilter("rule1", [('Sender', ":is", 'toto@toto.com')], [("fileinto", 'Toto')]) self.assertIsNot(self.fs.getfilter("rule1"), None) self.assertEqual(self.fs.disablefilter("rule1"), True) output = io.StringIO() self.fs.tosieve(output) self.assertEqual(output.getvalue(), """require ["fileinto"]; # Filter: rule1 if false { if anyof (header :is "Sender" "toto@toto.com") { fileinto "Toto"; } } """) output.close() self.assertEqual(self.fs.is_filter_disabled("rule1"), True) def test_add_filter_unicode(self): """Add a filter containing unicode data.""" name = u"Test\xe9".encode("utf-8") self.fs.addfilter( name, [('Sender', ":is", 'toto@toto.com'), ], [("fileinto", 'Toto'), ]) self.assertIsNot(self.fs.getfilter("Testé"), None) self.assertEqual("{}".format(self.fs), """require ["fileinto"]; # Filter: Testé if anyof (header :is "Sender" "toto@toto.com") { fileinto "Toto"; } """) def test_add_body_filter(self): """Add a body filter.""" self.fs.addfilter( "test", [("body", ":raw", ":contains", "matteo")], [("fileinto", "Toto")] ) self.assertEqual("{}".format(self.fs), """require ["body", "fileinto"]; # Filter: test if anyof (body :contains :raw ["matteo"]) { fileinto "Toto"; } """) def test_add_notbody_filter(self): """Add a not body filter.""" self.fs.addfilter( "test", [("body", ":raw", ":notcontains", "matteo")], [("fileinto", "Toto")] ) self.assertEqual("{}".format(self.fs), """require ["body", "fileinto"]; # Filter: test if anyof (not body :contains :raw ["matteo"]) { fileinto "Toto"; } """) def test_add_envelope_filter(self): """Add a envelope filter.""" self.fs.addfilter( "test", [("envelope", ":is", ["From"], ["hello"])], [("fileinto", "INBOX")] ) self.assertEqual("{}".format(self.fs), """require ["envelope", "fileinto"]; # Filter: test if anyof (envelope :is ["From"] ["hello"]) { fileinto "INBOX"; } """) def test_add_notenvelope_filter(self): """Add a not envelope filter.""" self.fs.addfilter( "test", [("envelope", ":notis", ["From"], ["hello"])], [("fileinto", "INBOX")] ) self.assertEqual("{}".format(self.fs), """require ["envelope", "fileinto"]; # Filter: test if anyof (not envelope :is ["From"] ["hello"]) { fileinto "INBOX"; } """) def test_add_currentdate_filter(self): """Add a currentdate filter.""" self.fs.addfilter( "test", [("currentdate", ":zone", "+0100", ":is", "date", "2019-02-26")], [("fileinto", "INBOX")] ) self.assertEqual("{}".format(self.fs), """require ["date", "fileinto"]; # Filter: test if anyof (currentdate :zone "+0100" :is "date" ["2019-02-26"]) { fileinto "INBOX"; } """) self.fs.removefilter("test") self.fs.addfilter( "test", [("currentdate", ":zone", "+0100", ":value", "gt", "date", "2019-02-26")], [("fileinto", "INBOX")] ) self.assertEqual("{}".format(self.fs), """require ["date", "fileinto", "relational"]; # Filter: test if anyof (currentdate :zone "+0100" :value "gt" "date" ["2019-02-26"]) { fileinto "INBOX"; } """) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/tests/test_managesieve.py0000664000372000037200000001253000000000000023022 0ustar00travistravis00000000000000"""Managesieve test cases.""" import unittest from unittest import mock from sievelib import managesieve CAPABILITIES = ( b'"IMPLEMENTATION" "Example1 ManageSieved v001"\r\n' b'"VERSION" "1.0"\r\n' b'"SASL" "PLAIN SCRAM-SHA-1 GSSAPI"\r\n' b'"SIEVE" "fileinto vacation"\r\n' b'"STARTTLS"\r\n' ) CAPABILITIES_WITHOUT_VERSION = ( b'"IMPLEMENTATION" "Example1 ManageSieved v001"\r\n' b'"SASL" "PLAIN SCRAM-SHA-1 GSSAPI"\r\n' b'"SIEVE" "fileinto vacation"\r\n' b'"STARTTLS"\r\n' ) AUTHENTICATION = ( CAPABILITIES + b'OK "Dovecot ready."\r\n' b'OK "Logged in."\r\n' ) LISTSCRIPTS = ( b'"summer_script"\r\n' b'"vac\xc3\xa0tion_script"\r\n' b'{13}\r\n' b'clever"script\r\n' b'"main_script" ACTIVE\r\n' b'OK "Listscripts completed."\r\n' ) GETSCRIPT = ( b'{54}\r\n' b'#this is my wonderful script\r\n' b'reject "I reject all";\r\n' b'OK "Getscript completed."\r\n' ) @mock.patch("socket.socket") class ManageSieveTestCase(unittest.TestCase): """Managesieve test cases.""" def setUp(self): """Create client.""" self.client = managesieve.Client("127.0.0.1") def authenticate(self, mock_socket): """Authenticate client.""" mock_socket.return_value.recv.side_effect = (AUTHENTICATION, ) self.client.connect(b"user", b"password") def test_connection(self, mock_socket): """Test connection.""" self.authenticate(mock_socket) self.assertEqual( self.client.get_sieve_capabilities(), ["fileinto", "vacation"]) mock_socket.return_value.recv.side_effect = (b"OK test\r\n", ) self.client.logout() def test_capabilities(self, mock_socket): """Test capabilities command.""" self.authenticate(mock_socket) mock_socket.return_value.recv.side_effect = ( CAPABILITIES + b'OK "Capability completed."\r\n', ) capabilities = self.client.capability() self.assertEqual(capabilities, CAPABILITIES) def test_listscripts(self, mock_socket): """Test listscripts command.""" self.authenticate(mock_socket) mock_socket.return_value.recv.side_effect = (LISTSCRIPTS, ) active_script, others = self.client.listscripts() self.assertEqual(active_script, "main_script") self.assertEqual( others, [u'summer_script', u'vacàtion_script', u'clever"script']) def test_getscript(self, mock_socket): """Test getscript command.""" self.authenticate(mock_socket) mock_socket.return_value.recv.side_effect = (GETSCRIPT, ) content = self.client.getscript("main_script") self.assertEqual( content, u'#this is my wonderful script\nreject "I reject all";') def test_putscript(self, mock_socket): """Test putscript command.""" self.authenticate(mock_socket) script = """require ["fileinto"]; if envelope :contains "to" "tmartin+sent" { fileinto "INBOX.sent"; } """ mock_socket.return_value.recv.side_effect = ( b'OK "putscript completed."\r\n', ) self.assertTrue(self.client.putscript(u"test_script", script)) def test_deletescript(self, mock_socket): """Test deletescript command.""" self.authenticate(mock_socket) mock_socket.return_value.recv.side_effect = ( b'OK "deletescript completed."\r\n', ) self.assertTrue(self.client.deletescript(u"test_script")) def test_checkscript(self, mock_socket): """Test checkscript command.""" self.authenticate(mock_socket) mock_socket.return_value.recv.side_effect = ( b'OK "checkscript completed."\r\n', ) script = "#comment\r\nInvalidSieveCommand\r\n" self.assertTrue(self.client.checkscript(script)) def test_setactive(self, mock_socket): """Test setactive command.""" self.authenticate(mock_socket) mock_socket.return_value.recv.side_effect = ( b'OK "setactive completed."\r\n', ) self.assertTrue(self.client.setactive(u"test_script")) def test_havespace(self, mock_socket): """Test havespace command.""" self.authenticate(mock_socket) mock_socket.return_value.recv.side_effect = ( b'OK "havespace completed."\r\n', ) self.assertTrue(self.client.havespace(u"test_script", 1000)) def test_renamescript(self, mock_socket): """Test renamescript command.""" self.authenticate(mock_socket) mock_socket.return_value.recv.side_effect = ( b'OK "renamescript completed."\r\n', ) self.assertTrue(self.client.renamescript(u"old_script", u"new_script")) def test_renamescript_simulated(self, mock_socket): """Test renamescript command simulation.""" mock_socket.return_value.recv.side_effect = ( CAPABILITIES_WITHOUT_VERSION + b'OK "Dovecot ready."\r\n' b'OK "Logged in."\r\n', ) self.client.connect(b"user", b"password") mock_socket.return_value.recv.side_effect = ( LISTSCRIPTS, GETSCRIPT, b'OK "putscript completed."\r\n', b'OK "setactive completed."\r\n', b'OK "deletescript completed."\r\n' ) self.assertTrue( self.client.renamescript(u"main_script", u"new_script")) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/tests/test_parser.py0000664000372000037200000005165300000000000022043 0ustar00travistravis00000000000000""" Unit tests for the SIEVE language parser. """ import unittest import os.path import codecs import io from sievelib.parser import Parser from sievelib.factory import FiltersSet import sievelib.commands class MytestCommand(sievelib.commands.ActionCommand): args_definition = [ {"name": "testtag", "type": ["tag"], "write_tag": True, "values": [":testtag"], "extra_arg": {"type": "number", "required": False}, "required": False}, {"name": "recipients", "type": ["string", "stringlist"], "required": True} ] class Quota_notificationCommand(sievelib.commands.ActionCommand): args_definition = [ {"name": "subject", "type": ["tag"], "write_tag": True, "values": [":subject"], "extra_arg": {"type": "string"}, "required": False}, {"name": "recipient", "type": ["tag"], "write_tag": True, "values": [":recipient"], "extra_arg": {"type": "stringlist"}, "required": True} ] class SieveTest(unittest.TestCase): def setUp(self): self.parser = Parser() def __checkCompilation(self, script, result): self.assertEqual(self.parser.parse(script), result) def compilation_ok(self, script, **kwargs): self.__checkCompilation(script, True, **kwargs) def compilation_ko(self, script): self.__checkCompilation(script, False) def representation_is(self, content): target = io.StringIO() self.parser.dump(target) repr_ = target.getvalue() target.close() self.assertEqual(repr_, content.lstrip()) def sieve_is(self, content): filtersset = FiltersSet("Testfilterset") filtersset.from_parser_result(self.parser) target = io.StringIO() filtersset.tosieve(target) repr_ = target.getvalue() target.close() self.assertEqual(repr_, content) class AdditionalCommands(SieveTest): def test_add_command(self): self.assertRaises( sievelib.commands.UnknownCommand, sievelib.commands.get_command_instance, 'mytest' ) sievelib.commands.add_commands(MytestCommand) sievelib.commands.get_command_instance('mytest') self.compilation_ok(b""" mytest :testtag 10 ["testrecp1@example.com"]; """) def test_quota_notification(self): sievelib.commands.add_commands(Quota_notificationCommand) quota_notification_sieve = """# Filter: Testrule\nquota_notification :subject "subject here" :recipient ["somerecipient@example.com"];\n""" self.compilation_ok(quota_notification_sieve) self.sieve_is(quota_notification_sieve) class ValidEncodings(SieveTest): def test_utf8_file(self): utf8_sieve = os.path.join( os.path.dirname(__file__), 'files', 'utf8_sieve.txt' ) with codecs.open(utf8_sieve, encoding='utf8') as fobj: source_sieve = fobj.read() self.parser.parse_file(utf8_sieve) self.sieve_is(source_sieve) class ValidSyntaxes(SieveTest): def test_hash_comment(self): self.compilation_ok(b""" if size :over 100k { # this is a comment discard; } """) self.representation_is(""" if (type: control) size (type: test) :over 100k discard (type: action) """) def test_bracket_comment(self): self.compilation_ok(b""" if size :over 100K { /* this is a comment this is still a comment */ discard /* this is a comment */ ; } """) self.representation_is(""" if (type: control) size (type: test) :over 100K discard (type: action) """) def test_string_with_bracket_comment(self): self.compilation_ok(b""" if header :contains "Cc" "/* comment */" { discard; } """) self.representation_is(""" if (type: control) header (type: test) :contains "Cc" "/* comment */" discard (type: action) """) def test_multiline_string(self): self.compilation_ok(b""" require "reject"; if allof (false, address :is ["From", "Sender"] ["blka@bla.com"]) { reject text: noreply ============================ Your email has been canceled ============================ . ; stop; } else { reject text: ================================ Your email has been canceled too ================================ . ; } """) self.representation_is(""" require (type: control) "reject" if (type: control) allof (type: test) false (type: test) address (type: test) :is ["From","Sender"] ["blka@bla.com"] reject (type: action) text: noreply ============================ Your email has been canceled ============================ . stop (type: action) else (type: control) reject (type: action) text: ================================ Your email has been canceled too ================================ . """) def test_complex_allof_with_not(self): """Test for allof/anyof commands including a not test. See https://github.com/tonioo/sievelib/issues/69. """ self.compilation_ok(b""" require ["fileinto", "reject"]; if allof (not allof (address :is ["From","sender"] ["test1@test2.priv","test2@test2.priv"], header :matches "Subject" "INACTIVE*"), address :is "From" "user3@test3.priv") { reject; } """) self.representation_is(""" require (type: control) ["fileinto","reject"] if (type: control) allof (type: test) not (type: test) allof (type: test) address (type: test) :is ["From","sender"] ["test1@test2.priv","test2@test2.priv"] header (type: test) :matches "Subject" "INACTIVE*" address (type: test) :is "From" "user3@test3.priv" reject (type: action) """) def test_nested_blocks(self): self.compilation_ok(b""" if header :contains "Sender" "example.com" { if header :contains "Sender" "me@" { discard; } elsif header :contains "Sender" "you@" { keep; } } """) self.representation_is(""" if (type: control) header (type: test) :contains "Sender" "example.com" if (type: control) header (type: test) :contains "Sender" "me@" discard (type: action) elsif (type: control) header (type: test) :contains "Sender" "you@" keep (type: action) """) def test_true_test(self): self.compilation_ok(b""" if true { } """) self.representation_is(""" if (type: control) true (type: test) """) def test_rfc5228_extended(self): self.compilation_ok(b""" # # Example Sieve Filter # Declare any optional features or extension used by the script # require ["fileinto"]; # # Handle messages from known mailing lists # Move messages from IETF filter discussion list to filter mailbox # if header :is "Sender" "owner-ietf-mta-filters@imc.org" { fileinto "filter"; # move to "filter" mailbox } # # Keep all messages to or from people in my company # elsif address :domain :is ["From", "To"] "example.com" { keep; # keep in "In" mailbox } # # Try and catch unsolicited email. If a message is not to me, # or it contains a subject known to be spam, file it away. # elsif anyof (NOT address :all :contains ["To", "Cc", "Bcc"] "me@example.com", header :matches "subject" ["*make*money*fast*", "*university*dipl*mas*"]) { fileinto "spam"; # move to "spam" mailbox } else { # Move all other (non-company) mail to "personal" # mailbox. fileinto "personal"; } """) self.representation_is(""" require (type: control) ["fileinto"] if (type: control) header (type: test) :is "Sender" "owner-ietf-mta-filters@imc.org" fileinto (type: action) "filter" elsif (type: control) address (type: test) :domain :is ["From","To"] "example.com" keep (type: action) elsif (type: control) anyof (type: test) not (type: test) address (type: test) :all :contains ["To","Cc","Bcc"] "me@example.com" header (type: test) :matches "subject" ["*make*money*fast*","*university*dipl*mas*"] fileinto (type: action) "spam" else (type: control) fileinto (type: action) "personal" """) def test_explicit_comparator(self): self.compilation_ok(b""" if header :contains :comparator "i;octet" "Subject" "MAKE MONEY FAST" { discard; } """) self.representation_is(""" if (type: control) header (type: test) :comparator "i;octet" :contains "Subject" "MAKE MONEY FAST" discard (type: action) """) def test_non_ordered_args(self): self.compilation_ok(b""" if address :all :is "from" "tim@example.com" { discard; } """) self.representation_is(""" if (type: control) address (type: test) :all :is "from" "tim@example.com" discard (type: action) """) def test_multiple_not(self): self.compilation_ok(b""" if not not not not true { stop; } """) self.representation_is(""" if (type: control) not (type: test) not (type: test) not (type: test) not (type: test) true (type: test) stop (type: action) """) def test_just_one_command(self): self.compilation_ok(b"keep;") self.representation_is(""" keep (type: action) """) def test_singletest_testlist(self): self.compilation_ok(b""" if anyof (true) { discard; } """) self.representation_is(""" if (type: control) anyof (type: test) true (type: test) discard (type: action) """) def test_multitest_testlist(self): self.compilation_ok(b""" if anyof(allof(address :contains "From" ""), allof(header :contains "Subject" "")) {} """) def test_truefalse_testlist(self): self.compilation_ok(b""" if anyof(true, false) { discard; } """) self.representation_is(""" if (type: control) anyof (type: test) true (type: test) false (type: test) discard (type: action) """) def test_vacationext_basic(self): self.compilation_ok(b""" require "vacation"; if header :contains "subject" "cyrus" { vacation "I'm out -- send mail to cyrus-bugs"; } else { vacation "I'm out -- call me at +1 304 555 0123"; } """) def test_vacationext_medium(self): self.compilation_ok(b""" require "vacation"; if header :contains "subject" "lunch" { vacation :handle "ran-away" "I'm out and can't meet for lunch"; } else { vacation :handle "ran-away" "I'm out"; } """) def test_vacationext_with_limit(self): self.compilation_ok(b""" require "vacation"; vacation :days 23 :addresses ["tjs@example.edu", "ts4z@landru.example.edu"] "I'm away until October 19. If it's an emergency, call 911, I guess." ; """) def test_vacationext_with_single_mail_address(self): self.compilation_ok(""" require "vacation"; vacation :days 23 :addresses "tjs@example.edu" "I'm away until October 19. If it's an emergency, call 911, I guess." ; """) def test_vacationext_with_multiline(self): self.compilation_ok(b""" require "vacation"; vacation :mime text: Content-Type: multipart/alternative; boundary=foo --foo I'm at the beach relaxing. Mmmm, surf... --foo Content-Type: text/html; charset=us-ascii How to relax

I'm at the beach relaxing. Mmmm, surf... --foo-- . ; """) def test_reject_extension(self): self.compilation_ok(b""" require "reject"; if header :contains "subject" "viagra" { reject; } """) def test_fileinto_create(self): self.compilation_ok(b"""require ["fileinto", "mailbox"]; if header :is "Sender" "owner-ietf-mta-filters@imc.org" { fileinto :create "filter"; # move to "filter" mailbox } """) def test_imap4flags_extension(self): self.compilation_ok(b""" require ["fileinto", "imap4flags", "variables"]; if size :over 1M { addflag "MyFlags" "Big"; if header :is "From" "boss@company.example.com" { # The message will be marked as "\Flagged Big" when filed into # mailbox "Big messages" addflag "MyFlags" "\\Flagged"; } fileinto :flags "${MyFlags}" "Big messages"; } """) def test_imap4flags_hasflag(self): self.compilation_ok(b""" require ["imap4flags", "fileinto"]; if hasflag ["test", "toto"] { fileinto "Test"; } addflag "Var1" "Truc"; if hasflag "Var1" "Truc" { fileinto "Truc"; } """) def test_body_extension(self): self.compilation_ok(b""" require ["body", "fileinto"]; if body :content "text" :contains ["missile", "coordinates"] { fileinto "secrets"; } """) self.compilation_ok(b""" require "body"; if body :raw :contains "MAKE MONEY FAST" { discard; } """) self.compilation_ok(b""" require ["body", "fileinto"]; # Save messages mentioning the project schedule in the # project/schedule folder. if body :text :contains "project schedule" { fileinto "project/schedule"; } """) class InvalidSyntaxes(SieveTest): def test_nested_comments(self): self.compilation_ko(b""" /* this is a comment /* with a nested comment inside */ it is allowed by the RFC :p */ """) def test_nonopened_block(self): self.compilation_ko(b""" if header :is "Sender" "me@example.com" discard; } """) def test_nonclosed_block(self): self.compilation_ko(b""" if header :is "Sender" "me@example.com" { discard; """) def test_nonopened_parenthesis(self): self.compilation_ko(b""" if header :is "Sender" "me@example.com") { discard; } """) def test_nonopened_block2(self): self.compilation_ko(b"""}""") def test_unknown_token(self): self.compilation_ko(b""" if header :is "Sender" "Toto" & header :contains "Cc" "Tata" { } """) def test_empty_string_list(self): self.compilation_ko(b"require [];") def test_unopened_string_list(self): self.compilation_ko(b'require "fileinto"];') def test_unclosed_string_list(self): self.compilation_ko(b'require ["toto", "tata";') def test_misplaced_comma_in_string_list(self): self.compilation_ko(b'require ["toto",];') def test_nonopened_tests_list(self): self.compilation_ko(b""" if anyof header :is "Sender" "me@example.com", header :is "Sender" "myself@example.com") { fileinto "trash"; } """) def test_nonclosed_tests_list(self): self.compilation_ko(b""" if anyof (header :is "Sender" "me@example.com", header :is "Sender" "myself@example.com" { fileinto "trash"; } """) def test_nonclosed_tests_list2(self): self.compilation_ko(b""" if anyof (header :is "Sender" { fileinto "trash"; } """) def test_misplaced_comma_in_tests_list(self): self.compilation_ko(b""" if anyof (header :is "Sender" "me@example.com",) { } """) def test_comma_inside_arguments(self): self.compilation_ko(b""" require "fileinto", "enveloppe"; """) def test_non_ordered_args(self): self.compilation_ko(b""" if address "From" :is "tim@example.com" { discard; } """) def test_extra_arg(self): self.compilation_ko(b""" if address :is "From" "tim@example.com" "tutu" { discard; } """) def test_empty_not(self): self.compilation_ko(b""" if not { discard; } """) def test_missing_semicolon(self): self.compilation_ko(b""" require ["fileinto"] """) def test_missing_semicolon_in_block(self): self.compilation_ko(b""" if true { stop } """) def test_misplaced_parenthesis(self): self.compilation_ko(b""" if (true) { } """) def test_control_command_in_test(self): self.compilation_ko(b""" if stop; """) def test_extra_test_in_simple_control(self): self.compilation_ko(b""" if address "From" "example.com" header "Subject" "Example" { stop; } """) def test_missing_comma_in_test_list(self): self.compilation_ko(b""" if allof(anyof(address "From" "example.com") header "Subject" "Example") { stop; } """) class LanguageRestrictions(SieveTest): def test_unknown_control(self): self.compilation_ko(b""" macommande "Toto"; """) def test_misplaced_elsif(self): self.compilation_ko(b""" elsif true { } """) def test_misplaced_elsif2(self): self.compilation_ko(b""" elsif header :is "From" "toto" { } """) def test_misplaced_nested_elsif(self): self.compilation_ko(b""" if true { elsif false { } } """) def test_unexpected_argument(self): self.compilation_ko(b'stop "toto";') def test_bad_arg_value(self): self.compilation_ko(b""" if header :isnot "Sent" "me@example.com" { stop; } """) def test_bad_arg_value2(self): self.compilation_ko(b""" if header :isnot "Sent" 10000 { stop; } """) def test_bad_comparator_value(self): self.compilation_ko(b""" if header :contains :comparator "i;prout" "Subject" "MAKE MONEY FAST" { discard; } """) def test_not_included_extension(self): self.compilation_ko(b""" if header :contains "Subject" "MAKE MONEY FAST" { fileinto "spam"; } """) def test_test_outside_control(self): self.compilation_ko(b"true;") def test_fileinto_create_without_mailbox(self): self.compilation_ko(b"""require ["fileinto"]; if header :is "Sender" "owner-ietf-mta-filters@imc.org" { fileinto :create "filter"; # move to "filter" mailbox } """) self.assertEqual( self.parser.error, "line 4: extension 'mailbox' not loaded") def test_fileinto_create_without_fileinto(self): self.compilation_ko(b"""require ["mailbox"]; if header :is "Sender" "owner-ietf-mta-filters@imc.org" { fileinto :create "filter"; # move to "filter" mailbox } """) self.assertEqual( self.parser.error, "line 4: unknown command fileinto") def test_exists_get_string_or_list(self): self.compilation_ok(b""" if exists "subject" { discard; } """) self.compilation_ok(b""" if exists ["subject"] { discard; } """) class DateCommands(SieveTest): def test_date_command(self): self.compilation_ok(b"""require ["date", "relational", "fileinto"]; if allof(header :is "from" "boss@example.com", date :value "ge" :originalzone "date" "hour" "09", date :value "lt" :originalzone "date" "hour" "17") { fileinto "urgent"; } """) def test_currentdate_command(self): self.compilation_ok(b"""require ["date", "relational"]; if allof(currentdate :value "ge" "date" "2013-10-23", currentdate :value "le" "date" "2014-10-12") { discard; } """) def test_currentdate_command_timezone(self): self.compilation_ok(b"""require ["date", "relational"]; if allof(currentdate :zone "+0100" :value "ge" "date" "2013-10-23", currentdate :value "le" "date" "2014-10-12") { discard; } """) def test_currentdate_norel(self): self.compilation_ok(b"""require ["date"]; if allof ( currentdate :zone "+0100" :is "date" "2013-10-23" ) { discard; }""") def test_currentdate_extension_not_loaded(self): self.compilation_ko(b"""require ["date"]; if allof ( currentdate :value "ge" "date" "2013-10-23" , currentdate :value "le" "date" "2014-10-12" ) { discard; } """) class VariablesCommands(SieveTest): def test_set_command(self): self.compilation_ok(b"""require ["variables"]; set "matchsub" "testsubject"; if allof ( header :contains ["Subject"] "${header}" ) { discard; } """) class CopyWithoutSideEffectsTestCase(SieveTest): """RFC3894 test cases.""" def test_redirect_with_copy(self): self.compilation_ko(b""" if header :contains "subject" "test" { redirect :copy "dev@null.com"; } """) self.compilation_ok(b"""require "copy"; if header :contains "subject" "test" { redirect :copy "dev@null.com"; } """) def test_fileinto_with_copy(self): self.compilation_ko(b"""require "fileinto"; if header :contains "subject" "test" { fileinto :copy "Spam"; } """) self.assertEqual( self.parser.error, "line 3: extension 'copy' not loaded") self.compilation_ok(b"""require ["fileinto", "copy"]; if header :contains "subject" "test" { fileinto :copy "Spam"; } """) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336608.0 sievelib-1.2.1/sievelib/tools.py0000664000372000037200000000041200000000000017471 0ustar00travistravis00000000000000"""Some tools.""" def to_list(stringlist, unquote=True): """Convert a string representing a list to real list.""" stringlist = stringlist[1:-1] return [ string.strip('"') if unquote else string for string in stringlist.split(",") ] ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1607336641.778098 sievelib-1.2.1/sievelib.egg-info/0000775000372000037200000000000000000000000017454 5ustar00travistravis00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336641.0 sievelib-1.2.1/sievelib.egg-info/PKG-INFO0000664000372000037200000001424100000000000020553 0ustar00travistravis00000000000000Metadata-Version: 1.1 Name: sievelib Version: 1.2.1 Summary: Client-side SIEVE library Home-page: https://github.com/tonioo/sievelib Author: Antoine Nguyen Author-email: tonio@ngyn.org License: MIT Description: sievelib ======== |travis| |codecov| |latest-version| Client-side Sieve and Managesieve library written in Python. * Sieve : An Email Filtering Language (`RFC 5228 `_) * ManageSieve : A Protocol for Remotely Managing Sieve Scripts (`RFC 5804 `_) Installation ------------ To install ``sievelib`` from PyPI:: pip install sievelib To install sievelib from git:: git clone git@github.com:tonioo/sievelib.git cd sievelib python ./setup.py install 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: * Copying Without Side Effects (`RFC 3894 `_) * Body (`RFC 5173 `_) * Vacation (`RFC 5230 `_) * Relational (`RFC 5231 `_) * Imap4flags (`RFC 5232 `_) The following extensions are partially supported: * Date and Index (`RFC 5260 `_) * Checking Mailbox Status and Accessing Mailbox Metadata (`RFC 5490 `_) Extending the parser ^^^^^^^^^^^^^^^^^^^^ It is possible to extend the parser by adding new supported commands. For example:: import sievelib class MyCommand(sievelib.commands.ActionCommand): args_definition = [ {"name": "testtag", "type": ["tag"], "write_tag": True, "values": [":testtag"], "extra_arg": {"type": "number", "required": False}, "required": False}, {"name": "recipients", "type": ["string", "stringlist"], "required": True} ] sievelib.commands.add_commands(MyCommand) 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 FiltersSet >>> fs = FiltersSet("test") >>> 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"; } >>> Additional documentation is available within 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 >>> Additional documentation is available with source code. .. |latest-version| image:: https://badge.fury.io/py/sievelib.svg :target: https://badge.fury.io/py/sievelib .. |travis| image:: https://travis-ci.org/tonioo/sievelib.png?branch=master :target: https://travis-ci.org/tonioo/sievelib .. |codecov| image:: http://codecov.io/github/tonioo/sievelib/coverage.svg?branch=master :target: http://codecov.io/github/tonioo/sievelib?branch=master 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 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336641.0 sievelib-1.2.1/sievelib.egg-info/SOURCES.txt0000664000372000037200000000101200000000000021332 0ustar00travistravis00000000000000.gitignore .travis.yml COPYING MANIFEST.in README.rst requirements.txt setup.cfg setup.py sievelib/__init__.py sievelib/commands.py sievelib/digest_md5.py sievelib/factory.py sievelib/managesieve.py sievelib/parser.py sievelib/tools.py sievelib.egg-info/PKG-INFO sievelib.egg-info/SOURCES.txt sievelib.egg-info/dependency_links.txt sievelib.egg-info/top_level.txt sievelib/tests/__init__.py sievelib/tests/test_factory.py sievelib/tests/test_managesieve.py sievelib/tests/test_parser.py sievelib/tests/files/utf8_sieve.txt././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336641.0 sievelib-1.2.1/sievelib.egg-info/dependency_links.txt0000664000372000037200000000000100000000000023522 0ustar00travistravis00000000000000 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1607336641.0 sievelib-1.2.1/sievelib.egg-info/top_level.txt0000664000372000037200000000001100000000000022176 0ustar00travistravis00000000000000sievelib