sievelib-1.1.1/0000775000372000037200000000000013232403207014152 5ustar travistravis00000000000000sievelib-1.1.1/README.rst0000664000372000037200000000771613232403146015656 0ustar travistravis00000000000000sievelib ======== |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 `_) * Date and Index (`RFC 5260 `_) * Vacation (`RFC 5230 `_) 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 sievelib-1.1.1/setup.py0000664000372000037200000000366213232403146015675 0ustar travistravis00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- """ A setuptools based setup module. See: https://packaging.python.org/en/latest/distributing.html """ from __future__ import unicode_literals import io from os import path from pip.req import parse_requirements from setuptools import setup, find_packages def get_requirements(requirements_file): """Use pip to parse requirements file.""" requirements = [] if path.isfile(requirements_file): for req in parse_requirements(requirements_file, session="hack"): # check markers, such as # # rope_py3k ; python_version >= '3.0' # if req.match_markers(): requirements.append(str(req.req)) return requirements if __name__ == "__main__": HERE = path.abspath(path.dirname(__file__)) INSTALL_REQUIRES = get_requirements(path.join(HERE, "requirements.txt")) 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=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 ) sievelib-1.1.1/COPYING0000664000372000037200000000207013232403146015206 0ustar travistravis00000000000000Copyright (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. sievelib-1.1.1/.gitignore0000664000372000037200000000004213232403146016140 0ustar travistravis00000000000000.*~$ *.pyc dist sievelib.egg-info sievelib-1.1.1/.travis.yml0000664000372000037200000000104613232403146016266 0ustar travistravis00000000000000language: python cache: pip python: - '2.7' - '3.4' - '3.6' 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.6' sievelib-1.1.1/setup.cfg0000664000372000037200000000010313232403207015765 0ustar travistravis00000000000000[bdist_wheel] universal = 1 [egg_info] tag_build = tag_date = 0 sievelib-1.1.1/requirements.txt0000664000372000037200000000001313232403146017432 0ustar travistravis00000000000000future six sievelib-1.1.1/sievelib/0000775000372000037200000000000013232403207015754 5ustar travistravis00000000000000sievelib-1.1.1/sievelib/digest_md5.py0000664000372000037200000000472213232403146020361 0ustar travistravis00000000000000# coding: utf-8 """ Simple Digest-MD5 implementation (client side) Implementation based on RFC 2831 (http://www.ietf.org/rfc/rfc2831.txt) """ import base64 import hashlib import binascii import re import random class DigestMD5(object): def __init__(self, challenge, digesturi): self.__digesturi = digesturi self.__challenge = challenge self.__params = {} pexpr = re.compile(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)) sievelib-1.1.1/sievelib/tools.py0000664000372000037200000000036013232403146017467 0ustar travistravis00000000000000"""Some tools.""" import six def to_bytes(s, encoding="utf-8"): """Convert a string to bytes.""" if isinstance(s, six.binary_type): return s if six.PY3: return bytes(s, encoding) return s.encode(encoding) sievelib-1.1.1/sievelib/commands.py0000664000372000037200000005510613232403146020140 0ustar travistravis00000000000000# coding: utf-8 """ SIEVE commands representation This module contains classes that represent known commands. They all inherit from the Command class which provides generic method for command manipulation or parsing. There are three command types (each one represented by a class): * control (ControlCommand) : Control structures are needed to allow for multiple and conditional actions * action (ActionCommand) : Actions that can be applied on emails * test (TestCommand) : Tests are used in conditionals to decide which part(s) of the conditional to execute Finally, each known command is represented by its own class which provides extra information such as: * expected arguments, * completion callback, * etc. """ from __future__ import unicode_literals from collections import Iterable import sys from future.utils import python_2_unicode_compatible class CommandError(Exception): """Base command exception class.""" pass @python_2_unicode_compatible 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 @python_2_unicode_compatible 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) @python_2_unicode_compatible 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) @python_2_unicode_compatible 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"], "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 accept_children = False must_follow = None is_extension = False def __init__(self, parent=None): self.parent = parent self.arguments = {} self.children = [] self.nextargpos = 0 self.required_args = -1 self.rargs_cnt = 0 self.curarg = None # for arguments that expect an argument :p (ex: :comparator) self.name = self.__class__.__name__.replace("Command", "") self.name = self.name.lower() 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"]] if "tag" in arg["type"] and arg.get("write_tag", False): target.write("%s " % arg["values"][0]) 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 arg["type"]: target.write(value) if 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 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"]] if type(value) == list: if self.__get_arg_type(arg["name"]) == ["testlist"]: for t in value: t.dump(indentlevel, target) else: self.__print("[" + (",".join(value)) + "]", indentlevel, target=target) continue if isinstance(value, Command): value.dump(indentlevel, target) continue self.__print(str(value), indentlevel, target=target) for ch in self.children: ch.dump(indentlevel, target) def addchild(self, child): """Add a new child to the command A child corresponds to a command located into a block (this command's block). It can be either an action or a control. :param child: the new child :return: True on succes, False otherwise """ if not self.accept_children: return False self.children += [child] return True def iscomplete(self): """Check if the command is complete Check if all required arguments have been encountered. For commands that allow an undefined number of arguments, this method always returns False. :return: True if command is complete, False otherwise """ if self.variable_args_nb: return False if self.required_args == -1: self.required_args = 0 for arg in self.args_definition: if arg["required"]: self.required_args += 1 return (not self.curarg or "extra_arg" not in self.curarg) \ 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 if value is allowed for arg Some commands only allow a limited set of values. The method always returns True for methods that do not provide such a set. :param arg: the argument's name :param value: the value to check :return: True on succes, False otherwise """ if "values" not in arg: return True return value.lower() in arg["values"] 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(): return False if self.curarg is not None and "extra_arg" in self.curarg: if atype in self.curarg["extra_arg"]["type"]: if "values" not in self.curarg["extra_arg"] \ or avalue in self.curarg["extra_arg"]["values"]: if add: self.arguments[self.curarg["name"]] = avalue self.curarg = None return True raise BadValue(self.curarg["name"], avalue) failed = False pos = self.nextargpos while pos < len(self.args_definition): curarg = self.args_definition[pos] if curarg["required"]: if curarg["type"] == ["testlist"]: if atype != "test": failed = True elif add: if not curarg["name"] in self.arguments: self.arguments[curarg["name"]] = [] self.arguments[curarg["name"]] += [avalue] elif atype not in curarg["type"] or \ not self.__is_valid_value_for_arg(curarg, avalue): failed = True else: self.curarg = curarg self.rargs_cnt += 1 self.nextargpos = pos + 1 if add: self.arguments[curarg["name"]] = avalue break if atype in curarg["type"]: ext = curarg.get("extension") condition = ( check_extension and ext and ext not in RequireCommand.loaded_extensions) if condition: raise ExtensionNotLoaded(ext) if self.__is_valid_value_for_arg(curarg, avalue): if "extra_arg" in curarg: self.curarg = curarg break if add: self.arguments[curarg["name"]] = avalue break pos += 1 if failed: raise BadArgument(self.name, avalue, self.args_definition[pos]["type"]) return True def __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 StopCommand(ControlCommand): args_definition = [] class IfCommand(ControlCommand): accept_children = True args_definition = [ {"name": "test", "type": ["test"], "required": True} ] def get_expected_first(self): return ["identifier"] class ElsifCommand(ControlCommand): accept_children = True must_follow = ["if", "elsif"] args_definition = [ {"name": "test", "type": ["test"], "required": True} ] def get_expected_first(self): return ["identifier"] class ElseCommand(ControlCommand): accept_children = True must_follow = ["if", "elsif"] args_definition = [] class ActionCommand(Command): """Indermediate class to represent "action" commands""" _type = "action" class FileintoCommand(ActionCommand): is_extension = True args_definition = [ {"name": "copy", "type": ["tag"], "values": [":copy"], "required": False, "extension": "copy"}, {"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): is_extension = True args_definition = [ {"name": "text", "type": ["string"], "required": True} ] class KeepCommand(ActionCommand): args_definition = [] class DiscardCommand(ActionCommand): args_definition = [] class TestCommand(Command): """Indermediate class to represent "test" commands""" _type = "test" class AddressCommand(TestCommand): args_definition = [ comparator, address_part, match_type, {"name": "header-list", "type": ["string", "stringlist"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True} ] class AllofCommand(TestCommand): accept_children = True variable_args_nb = True args_definition = [ {"name": "tests", "type": ["testlist"], "required": True} ] def get_expected_first(self): return ["left_parenthesis"] class AnyofCommand(TestCommand): accept_children = True variable_args_nb = True args_definition = [ {"name": "tests", "type": ["testlist"], "required": True} ] def get_expected_first(self): return ["left_parenthesis"] class EnvelopeCommand(TestCommand): args_definition = [ comparator, address_part, match_type, {"name": "header-list", "type": ["string", "stringlist"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True} ] class ExistsCommand(TestCommand): args_definition = [ {"name": "header-names", "type": ["stringlist"], "required": True} ] class TrueCommand(TestCommand): args_definition = [] class FalseCommand(TestCommand): args_definition = [] class HeaderCommand(TestCommand): args_definition = [ comparator, match_type, {"name": "header-names", "type": ["string", "stringlist"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True} ] class NotCommand(TestCommand): accept_children = True args_definition = [ {"name": "test", "type": ["test"], "required": True} ] def get_expected_first(self): return ["identifier"] class SizeCommand(TestCommand): args_definition = [ {"name": "comparator", "type": ["tag"], "values": [":over", ":under"], "required": True}, {"name": "limit", "type": ["number"], "required": True}, ] class VacationCommand(ActionCommand): args_definition = [ {"name": "subject", "type": ["tag"], "write_tag": True, "values": [":subject"], "extra_arg": {"type": "string"}, "required": False}, {"name": "days", "type": ["tag"], "write_tag": True, "values": [":days"], "extra_arg": {"type": "number"}, "required": False}, {"name": "from", "type": ["tag"], "write_tag": True, "values": [":from"], "extra_arg": {"type": "string"}, "required": False}, {"name": "addresses", "type": ["tag"], "write_tag": True, "values": [":addresses"], "extra_arg": {"type": ["string", "stringlist"]}, "required": False}, {"name": "handle", "type": ["tag"], "write_tag": True, "values": [":handle"], "extra_arg": {"type": "string"}, "required": False}, {"name": "mime", "type": ["tag"], "write_tag": True, "values": [":mime"], "required": False}, {"name": "reason", "type": ["string"], "required": True}, ] class SetCommand(ControlCommand): """currentdate command, part of the variables extension http://tools.ietf.org/html/rfc5229 """ is_extension = True args_definition = [ {"name": "startend", "type": ["string"], "required": True}, {"name": "date", "type": ["string"], "required": True} ] class CurrentdateCommand(ControlCommand): """currentdate command, part of the date extension http://tools.ietf.org/html/rfc5260#section-5 """ is_extension = True accept_children = True args_definition = [ {"name": "zone", "type": ["tag"], "write_tag": True, "values": [":zone"], "extra_arg": {"type": "string"}, "required": False}, {"name": "match-value", "type": ["tag"], "required": True}, {"name": "comparison", "type": ["string"], "required": True}, {"name": "match-against", "type": ["string"], "required": True}, {"name": "match-against-field", "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 """ # Mapping between extension names and command names extension_map = { 'date': set(['currentdate']), 'variables': set(['set']) } extname = name for extension in extension_map: if name in extension_map[extension]: extname = extension break cname = "%sCommand" % name.lower().capitalize() if cname not in globals() or \ (checkexists and globals()[cname].is_extension and extname not in RequireCommand.loaded_extensions): raise UnknownCommand(name) return globals()[cname](parent) sievelib-1.1.1/sievelib/factory.py0000664000372000037200000003573613232403146020015 0ustar travistravis00000000000000# coding: utf-8 """ Tools for simpler sieve filters generation. This module is intented to facilitate the creation of sieve filters without having to write or to know the syntax. Only commands (control/test/action) defined in the ``commands`` module are supported. """ from __future__ import print_function, unicode_literals import sys from future.utils import python_2_unicode_compatible import six from sievelib.commands import ( get_command_instance, IfCommand, RequireCommand, FalseCommand ) @python_2_unicode_compatible 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 = six.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, IfCommand): return False if not isinstance(fcontent["test"], FalseCommand): return False return True def from_parser_result(self, parser): cpt = 1 for f in parser.result: if isinstance(f, RequireCommand): if type(f.arguments["capabilities"]) == list: [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, six.binary_type): 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 = 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 = 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 = get_command_instance("if") mtypeobj = 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 = get_command_instance(c[0], ifcontrol) elif cname == "size": cmd = get_command_instance("size", ifcontrol) cmd.check_next_arg("tag", c[1]) cmd.check_next_arg("number", c[2]) elif cname == "exists": cmd = get_command_instance("exists", ifcontrol) cmd.check_next_arg( "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[1:])) ) else: if c[1].startswith(':not'): cmd = self.__build_condition( c, ifcontrol, c[1].replace("not", "", 1)) not_cmd = get_command_instance("not", ifcontrol) not_cmd.check_next_arg("test", cmd) cmd = not_cmd else: cmd = self.__build_condition(c, ifcontrol) if negate: not_cmd = 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 = get_command_instance(actdef[0], ifcontrol, False) if action.is_extension: self.require(actdef[0]) 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, six.binary_type) 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 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 = get_command_instance("if") falsecmd = get_command_instance("false", ifcontrol) ifcontrol.check_next_arg("test", falsecmd) for f in self.filters: if f["name"] != name: continue ifcontrol.addchild(f["content"]) f["content"] = ifcontrol f["enabled"] = False return True return False def movefilter(self, name, direction): """Moves the filter up or down :param name: the filter's name :param direction: string "up" or "down" """ 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() sievelib-1.1.1/sievelib/__init__.py0000664000372000037200000000042513232403146020070 0ustar travistravis00000000000000# -*- coding: utf-8 -*- """sievelib.""" from __future__ import unicode_literals from pkg_resources import get_distribution, DistributionNotFound try: __version__ = get_distribution(__name__).version except DistributionNotFound: # package is not installed pass sievelib-1.1.1/sievelib/parser.py0000775000372000037200000003722313232403146017636 0ustar travistravis00000000000000#!/usr/bin/env python # coding: utf-8 """ This module provides a simple but functional parser for the SIEVE language used to filter emails. This implementation is based on RFC 5228 (http://tools.ietf.org/html/rfc5228) """ from __future__ import print_function import re import sys from future.utils import python_2_unicode_compatible, text_type import six from sievelib.commands import ( get_command_instance, CommandError, RequireCommand) @python_2_unicode_compatible 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 = "(?P<%s>%s)" % (name.decode(), part.decode()) if six.PY3: param = bytes(param, "utf-8") 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 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: raise ParseError("unknown token %s" % text[self.pos:]) self.pos = m.end() yield (m.lastgroup, m.group(m.lastgroup)) class Parser(object): """The grammatical analysis part. Here we define the SIEVE language tokens and grammar. This class works with a Lexer object in order to check for grammar validity. """ lrules = [ (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.__opened_blocks = 0 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 __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 not onlyrecord: self.__curcommand = self.__curcommand.parent def __check_command_completion(self, testsemicolon=True): """Check for command(s) completion This function should be called each time a new argument is seen by the parser in order to check a command is complete. As not only one command can be ended when receiving a new argument (nested commands case), we apply the same work to parent commands. :param testsemicolon: if True, indicates that the next expected token must be a semicolon (for commands that need one) :return: True if command is considered as complete, False otherwise. """ if not self.__curcommand.iscomplete(): return True ctype = self.__curcommand.get_type() if ctype == "action" or \ (ctype == "control" and not self.__curcommand.accept_children): if testsemicolon: self.__set_expected("semicolon") return True while self.__curcommand.parent: cmd = self.__curcommand self.__curcommand = self.__curcommand.parent if self.__curcommand.get_type() in ["control", "test"]: if self.__curcommand.iscomplete(): if self.__curcommand.get_type() == "control": break continue if not self.__curcommand.check_next_arg("test", cmd, add=False): return False if not self.__curcommand.iscomplete(): if self.__curcommand.variable_args_nb: self.__set_expected("comma", "right_parenthesis") break return True def __stringlist(self, ttype, tvalue): """Specific method to parse the 'string-list' type Syntax: string-list = "[" string *("," string) "]" / string ; if there is only a single string, the brackets ; are optional """ if ttype == "string": self.__curstringlist += [tvalue.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.__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.__cstate = self.__stringlist self.__curstringlist = [] self.__set_expected("string") return True return False def __arguments(self, ttype, tvalue): """Arguments parsing method Entry point for command arguments parsing. The parser must call this method for each parsed command (either a control, action or test). Syntax: *argument [ test / test-list ] :param ttype: current token type :param tvalue: current token value :return: False if an error is encountered, True otherwise """ if ttype == "identifier": test = get_command_instance(tvalue.decode("ascii"), self.__curcommand) self.__curcommand.check_next_arg("test", test) self.__expected = test.get_expected_first() self.__curcommand = test return self.__check_command_completion(testsemicolon=False) if ttype == "left_parenthesis": self.__set_expected("identifier") return True if ttype == "comma": self.__set_expected("identifier") return True if ttype == "right_parenthesis": self.__up() return True if self.__argument(ttype, tvalue): return self.__check_command_completion(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.__up() self.__opened_blocks -= 1 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.__opened_blocks += 1 self.__cstate = None return True if ttype == "semicolon": self.__cstate = None if not self.__check_command_completion(testsemicolon=False): return False self.__curcommand.complete_cb() self.__up() return True return False def parse(self, text): """The parser entry point. Parse the provided text to check for its validity. On success, the parsing tree is available into the result attribute. It is a list of sievecommands.Command objects (see the module documentation for specific information). On error, an string containing the explicit reason is available into the error attribute. :param text: a string containing the data to parse :return: True on success (no error detected), False otherwise """ if isinstance(text, text_type): text = text.encode("utf-8") self.__reset_parser() try: 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): msg = "%s found while %s expected near '%s'" \ % (ttype, "|".join(self.__expected), text[self.lexer.pos]) else: msg = "%s found while %s expected at end of file" \ % (ttype, "|".join(self.__expected)) raise ParseError(msg) self.__expected = None if not self.__command(ttype, tvalue): msg = "unexpected token '%s' found near '%s'" \ % (tvalue, text[self.lexer.pos]) raise ParseError(msg) if self.__opened_blocks: self.__set_expected("right_cbracket") if self.__expected is not None: raise ParseError("end of script reached while %s expected" % "|".join(self.__expected)) except (ParseError, CommandError) as e: self.error = "line %d: %s" % (self.lexer.curlineno(), str(e)) return False return True def parse_file(self, name): """Parse the content of a file. See 'parse' method for information. :param name: the pathname of the file to parse :return: True on success (no error detected), False otherwise """ 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) sievelib-1.1.1/sievelib/tests/0000775000372000037200000000000013232403207017116 5ustar travistravis00000000000000sievelib-1.1.1/sievelib/tests/files/0000775000372000037200000000000013232403207020220 5ustar travistravis00000000000000sievelib-1.1.1/sievelib/tests/files/utf8_sieve.txt0000664000372000037200000000026613232403146023050 0ustar travistravis00000000000000require ["fileinto", "reject"]; # Filter: UTF8 Test Filter äöüß 汉语/漢語 Hànyǔ if allof (header :contains ["Subject"] ["€ 300"]) { fileinto "Spam"; stop; } sievelib-1.1.1/sievelib/tests/test_parser.py0000664000372000037200000004032713232403146022033 0ustar travistravis00000000000000# coding: utf-8 """ Unit tests for the SIEVE language parser. """ import unittest import os.path import codecs import six 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): self.__checkCompilation(script, True) def compilation_ko(self, script): self.__checkCompilation(script, False) def representation_is(self, content): target = six.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 = six.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: control) else (type: control) reject (type: action) text: ================================ Your email has been canceled too ================================ . """) 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) "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: control) """) 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_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; } """) 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_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_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) { } """) 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;") class DateCommands(SieveTest): 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; }""") 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() sievelib-1.1.1/sievelib/tests/__init__.py0000664000372000037200000000000013232403146021217 0ustar travistravis00000000000000sievelib-1.1.1/sievelib/tests/test_factory.py0000664000372000037200000001154713232403146022210 0ustar travistravis00000000000000# coding: utf-8 from __future__ import unicode_literals import unittest import six from sievelib.factory import FiltersSet class FactoryTestCase(unittest.TestCase): def setUp(self): self.fs = FiltersSet("test") def test_add_header_filter(self): output = six.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 = six.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 = six.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 = six.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 = six.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 = six.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 = six.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"; } """) if __name__ == "__main__": unittest.main() sievelib-1.1.1/sievelib/tests/test_managesieve.py0000664000372000037200000001262613232403146023024 0ustar travistravis00000000000000# coding: utf-8 """Managesieve test cases.""" import unittest try: from unittest import mock except ImportError: 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() sievelib-1.1.1/sievelib/managesieve.py0000664000372000037200000005435513232403146020630 0ustar travistravis00000000000000# coding: utf-8 """ A MANAGESIEVE client. A protocol for securely managing Sieve scripts on a remote server. This protocol allows a user to have multiple scripts, and also alerts a user to syntactically flawed scripts. Implementation based on RFC 5804. """ from __future__ import print_function import base64 import re import socket import ssl from future.utils import python_2_unicode_compatible import six 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 @python_2_unicode_compatible class Response(Exception): def __init__(self, code, data): self.code = code self.data = data def __str__(self): return "%s %s" % (self.code, self.data) @python_2_unicode_compatible 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, six.binary_type): if self.__size_expr.match(a): ret += [a] else: ret += [b'"' + a + b'"'] continue ret += [bytes(str(a).encode("utf-8"))] return ret 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, six.binary_type): code = code.decode("utf-8") 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, six.text_type): login = login.encode("utf-8") if isinstance(password, six.text_type): 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"], six.string_types): 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.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((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 = tools.to_bytes(content) content = tools.to_bytes("{%d+}" % len(content)) + CRLF + 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 = tools.to_bytes( u"{%d+}%s%s" % (len(content), str(CRLF), content)) code, data = self.__send_command("CHECKSCRIPT", [content]) if code == "OK": return True return False sievelib-1.1.1/MANIFEST.in0000664000372000037200000000013013232403146015704 0ustar travistravis00000000000000include README.rst COPYING requirements.txt include sievelib/tests/files/utf8_sieve.txt sievelib-1.1.1/PKG-INFO0000664000372000037200000001345513232403207015257 0ustar travistravis00000000000000Metadata-Version: 1.1 Name: sievelib Version: 1.1.1 Summary: Client-side SIEVE library Home-page: https://github.com/tonioo/sievelib Author: Antoine Nguyen Author-email: tonio@ngyn.org License: MIT Description-Content-Type: UNKNOWN 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 `_) * Date and Index (`RFC 5260 `_) * Vacation (`RFC 5230 `_) 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 sievelib-1.1.1/sievelib.egg-info/0000775000372000037200000000000013232403207017446 5ustar travistravis00000000000000sievelib-1.1.1/sievelib.egg-info/top_level.txt0000664000372000037200000000001113232403207022170 0ustar travistravis00000000000000sievelib sievelib-1.1.1/sievelib.egg-info/requires.txt0000664000372000037200000000001313232403207022040 0ustar travistravis00000000000000future six sievelib-1.1.1/sievelib.egg-info/PKG-INFO0000664000372000037200000001345513232403207020553 0ustar travistravis00000000000000Metadata-Version: 1.1 Name: sievelib Version: 1.1.1 Summary: Client-side SIEVE library Home-page: https://github.com/tonioo/sievelib Author: Antoine Nguyen Author-email: tonio@ngyn.org License: MIT Description-Content-Type: UNKNOWN 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 `_) * Date and Index (`RFC 5260 `_) * Vacation (`RFC 5230 `_) 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 sievelib-1.1.1/sievelib.egg-info/dependency_links.txt0000664000372000037200000000000113232403207023514 0ustar travistravis00000000000000 sievelib-1.1.1/sievelib.egg-info/SOURCES.txt0000664000372000037200000000105113232403207021327 0ustar travistravis00000000000000.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/requires.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