././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7449577 sievelib-1.4.2/0000755000175100001660000000000014737442260012734 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7399578 sievelib-1.4.2/.github/0000755000175100001660000000000014737442260014274 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7419577 sievelib-1.4.2/.github/workflows/0000755000175100001660000000000014737442260016331 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/.github/workflows/sievelib.yml0000644000175100001660000000463414737442253020667 0ustar00runnerdockername: Sievelib on: push: branches: [ master ] pull_request: branches: [ master ] release: branches: [ master ] types: [ published ] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: [3.8, 3.9, '3.10', '3.11'] fail-fast: false steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install codecov pytest pytest-cov pip install -e . - name: Run tests if: ${{ matrix.python-version != '3.11' }} run: | pytest - name: Run tests and coverage if: ${{ matrix.python-version == '3.11' }} run: | pytest --cov=sievelib --cov-report xml - name: Upload coverage result if: ${{ matrix.python-version == '3.11' }} uses: actions/upload-artifact@v4 with: name: coverage-results path: coverage.xml coverage: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download coverage results uses: actions/download-artifact@v4 with: name: coverage-results - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml release: if: github.event_name != 'pull_request' needs: coverage runs-on: ubuntu-latest permissions: id-token: write environment: name: pypi url: https://pypi.org/p/sievelib steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python 3.11 uses: actions/setup-python@v5 with: python-version: '3.11' - name: Create package run: | python setup.py sdist - name: Publish to Test PyPI if: endsWith(github.event.ref, '/master') uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ skip-existing: true - name: Publish distribution to PyPI if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/.gitignore0000644000175100001660000000004214737442253014722 0ustar00runnerdocker.*~$ *.pyc dist sievelib.egg-info ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/.pre-commit-config.yaml0000644000175100001660000000057714737442253017230 0ustar00runnerdocker# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: local hooks: - id: pylint name: pylint entry: pylint language: system types: [python] args: ["-rn", "-sn", "--fail-under=5"] - repo: https://github.com/psf/black rev: "24.2.0" hooks: - id: black ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/COPYING0000644000175100001660000000207014737442253013770 0ustar00runnerdockerCopyright (c) 2011-2024 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. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/MANIFEST.in0000644000175100001660000000013014737442253014466 0ustar00runnerdockerinclude README.rst COPYING requirements.txt include sievelib/tests/files/utf8_sieve.txt ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7449577 sievelib-1.4.2/PKG-INFO0000644000175100001660000001215114737442260014031 0ustar00runnerdockerMetadata-Version: 2.1 Name: sievelib Version: 1.4.2 Summary: Client-side SIEVE library Home-page: https://github.com/tonioo/sievelib Author: Antoine Nguyen Author-email: tonio@ngyn.org License: MIT Keywords: sieve,managesieve,parser,client Classifier: Programming Language :: Python Classifier: Development Status :: 5 - Production/Stable 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 Requires-Python: >=3.7 License-File: COPYING sievelib ======== |workflow| |codecov| |latest-version| Client-side Sieve and Managesieve library written in Python. * Sieve : An Email Filtering Language (`RFC 5228 `_) * ManageSieve : A Protocol for Remotely Managing Sieve Scripts (`RFC 5804 `_) Installation ------------ To install ``sievelib`` from PyPI:: pip install sievelib To install sievelib from git:: git clone git@github.com:tonioo/sievelib.git cd sievelib python ./setup.py install Sieve tools ----------- What is supported ^^^^^^^^^^^^^^^^^ Currently, the provided parser supports most of the functionalities described in the RFC. The only exception concerns section *2.4.2.4. Encoding Characters Using "encoded-character"* which is not supported. The following extensions are also supported: * Copying Without Side Effects (`RFC 3894 `_) * Body (`RFC 5173 `_) * Vacation (`RFC 5230 `_) * Seconds parameter for Vacation (`RFC 6131 `_) * Relational (`RFC 5231 `_) * Imap4flags (`RFC 5232 `_) * Regular expression (`Draft `_) The following extensions are partially supported: * Date and Index (`RFC 5260 `_) * Checking Mailbox Status and Accessing Mailbox Metadata (`RFC 5490 `_) Extending the parser ^^^^^^^^^^^^^^^^^^^^ It is possible to extend the parser by adding new supported commands. For example:: import sievelib class MyCommand(sievelib.commands.ActionCommand): args_definition = [ {"name": "testtag", "type": ["tag"], "write_tag": True, "values": [":testtag"], "extra_arg": {"type": "number", "required": False}, "required": False}, {"name": "recipients", "type": ["string", "stringlist"], "required": True} ] sievelib.commands.add_commands(MyCommand) Basic usage ^^^^^^^^^^^ The parser can either be used from the command-line:: $ cd sievelib $ python parser.py test.sieve Syntax OK $ Or can be used from a python environment (or script/module):: >>> from sievelib.parser import Parser >>> p = Parser() >>> p.parse('require ["fileinto"];') True >>> p.dump() require (type: control) ["fileinto"] >>> >>> p.parse('require ["fileinto"]') False >>> p.error 'line 1: parsing error: end of script reached while semicolon expected' >>> Simple filters creation ^^^^^^^^^^^^^^^^^^^^^^^ Some high-level classes are provided with the ``factory`` module, they make the generation of Sieve rules easier:: >>> from sievelib.factory import FiltersSet >>> fs = FiltersSet("test") >>> fs.addfilter("rule1", ... [("Sender", ":is", "toto@toto.com"),], ... [("fileinto", "Toto"),]) >>> fs.tosieve() require ["fileinto"]; # Filter: rule1 if anyof (header :is "Sender" "toto@toto.com") { fileinto "Toto"; } >>> Additional documentation is available within source code. ManageSieve tools ----------------- What is supported ^^^^^^^^^^^^^^^^^ All mandatory commands are supported. The ``RENAME`` extension is supported, with a simulated behaviour for server that do not support it. For the ``AUTHENTICATE`` command, supported mechanisms are ``DIGEST-MD5``, ``PLAIN``, ``LOGIN`` and ``OAUTHBEARER``. 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 .. |workflow| image:: https://github.com/tonioo/sievelib/workflows/Sievelib/badge.svg .. |codecov| image:: https://codecov.io/github/tonioo/sievelib/graph/badge.svg?token=B1FWNSY60d :target: https://codecov.io/github/tonioo/sievelib ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/README.rst0000644000175100001660000001073114737442253014427 0ustar00runnerdockersievelib ======== |workflow| |codecov| |latest-version| Client-side Sieve and Managesieve library written in Python. * Sieve : An Email Filtering Language (`RFC 5228 `_) * ManageSieve : A Protocol for Remotely Managing Sieve Scripts (`RFC 5804 `_) Installation ------------ To install ``sievelib`` from PyPI:: pip install sievelib To install sievelib from git:: git clone git@github.com:tonioo/sievelib.git cd sievelib python ./setup.py install Sieve tools ----------- What is supported ^^^^^^^^^^^^^^^^^ Currently, the provided parser supports most of the functionalities described in the RFC. The only exception concerns section *2.4.2.4. Encoding Characters Using "encoded-character"* which is not supported. The following extensions are also supported: * Copying Without Side Effects (`RFC 3894 `_) * Body (`RFC 5173 `_) * Vacation (`RFC 5230 `_) * Seconds parameter for Vacation (`RFC 6131 `_) * Relational (`RFC 5231 `_) * Imap4flags (`RFC 5232 `_) * Regular expression (`Draft `_) The following extensions are partially supported: * Date and Index (`RFC 5260 `_) * Checking Mailbox Status and Accessing Mailbox Metadata (`RFC 5490 `_) Extending the parser ^^^^^^^^^^^^^^^^^^^^ It is possible to extend the parser by adding new supported commands. For example:: import sievelib class MyCommand(sievelib.commands.ActionCommand): args_definition = [ {"name": "testtag", "type": ["tag"], "write_tag": True, "values": [":testtag"], "extra_arg": {"type": "number", "required": False}, "required": False}, {"name": "recipients", "type": ["string", "stringlist"], "required": True} ] sievelib.commands.add_commands(MyCommand) Basic usage ^^^^^^^^^^^ The parser can either be used from the command-line:: $ cd sievelib $ python parser.py test.sieve Syntax OK $ Or can be used from a python environment (or script/module):: >>> from sievelib.parser import Parser >>> p = Parser() >>> p.parse('require ["fileinto"];') True >>> p.dump() require (type: control) ["fileinto"] >>> >>> p.parse('require ["fileinto"]') False >>> p.error 'line 1: parsing error: end of script reached while semicolon expected' >>> Simple filters creation ^^^^^^^^^^^^^^^^^^^^^^^ Some high-level classes are provided with the ``factory`` module, they make the generation of Sieve rules easier:: >>> from sievelib.factory import FiltersSet >>> fs = FiltersSet("test") >>> fs.addfilter("rule1", ... [("Sender", ":is", "toto@toto.com"),], ... [("fileinto", "Toto"),]) >>> fs.tosieve() require ["fileinto"]; # Filter: rule1 if anyof (header :is "Sender" "toto@toto.com") { fileinto "Toto"; } >>> Additional documentation is available within source code. ManageSieve tools ----------------- What is supported ^^^^^^^^^^^^^^^^^ All mandatory commands are supported. The ``RENAME`` extension is supported, with a simulated behaviour for server that do not support it. For the ``AUTHENTICATE`` command, supported mechanisms are ``DIGEST-MD5``, ``PLAIN``, ``LOGIN`` and ``OAUTHBEARER``. 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 .. |workflow| image:: https://github.com/tonioo/sievelib/workflows/Sievelib/badge.svg .. |codecov| image:: https://codecov.io/github/tonioo/sievelib/graph/badge.svg?token=B1FWNSY60d :target: https://codecov.io/github/tonioo/sievelib ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/dev-requirements.txt0000644000175100001660000000004114737442253016771 0ustar00runnerdockerpre-commit black pylint pytest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/requirements.txt0000644000175100001660000000023314737442253016220 0ustar00runnerdocker# Requirements are listed in setup.py. The dot on the next line refers to # the current directory, instructing installers to use this package's setup.py . ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7449577 sievelib-1.4.2/setup.cfg0000644000175100001660000000010314737442260014547 0ustar00runnerdocker[bdist_wheel] universal = 1 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/setup.py0000644000175100001660000000304214737442253014447 0ustar00runnerdocker#!/usr/bin/env python """ A setuptools based setup module. See: https://packaging.python.org/en/latest/distributing.html """ import io from os import path from setuptools import setup, find_packages def local_scheme(version): """ Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) to be able to upload to Test PyPI """ return "" if __name__ == "__main__": HERE = path.abspath(path.dirname(__file__)) with io.open(path.join(HERE, "README.rst"), encoding="utf-8") as readme: LONG_DESCRIPTION = readme.read() setup( name="sievelib", packages=find_packages(), include_package_data=True, description="Client-side SIEVE library", author="Antoine Nguyen", author_email="tonio@ngyn.org", url="https://github.com/tonioo/sievelib", license="MIT", keywords=["sieve", "managesieve", "parser", "client"], install_requires=["typing-extensions"], setup_requires=["setuptools_scm"], use_scm_version={"local_scheme": local_scheme}, classifiers=[ "Programming Language :: Python", "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Communications :: Email :: Filters", ], python_requires=">=3.7", long_description=LONG_DESCRIPTION, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7429578 sievelib-1.4.2/sievelib/0000755000175100001660000000000014737442260014536 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/__init__.py0000644000175100001660000000000014737442253016637 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/commands.py0000644000175100001660000010460514737442253016721 0ustar00runnerdocker""" 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 collections.abc import Iterable import sys from typing import Any, Dict, Iterator, List, Optional, TypedDict, Union from typing_extensions import NotRequired from . import tools class CommandError(Exception): """Base command exception class.""" class UnknownCommand(CommandError): """Specific exception raised when an unknown command is encountered""" def __init__(self, name: str): self.name = name def __str__(self): return "unknown command '%s'" % self.name class BadArgument(CommandError): """Specific exception raised when a bad argument is encountered""" def __init__(self, command, seen, expected): self.command = command self.seen = seen self.expected = expected def __str__(self): return "bad argument %s for command %s (%s expected)" % ( self.seen, self.command, self.expected, ) class BadValue(CommandError): """Specific exception raised when a bad argument value is encountered""" def __init__(self, argument, value): self.argument = argument self.value = value def __str__(self): return "bad value %s for argument %s" % (self.value, self.argument) class ExtensionNotLoaded(CommandError): """Raised when an extension is not loaded.""" def __init__(self, name): self.name = name def __str__(self): return "extension '{}' not loaded".format(self.name) class CommandExtraArg(TypedDict): """Type definition for command extra argument.""" type: Union[str, List[str]] values: NotRequired[List[str]] valid_for: NotRequired[List[str]] class CommandArg(TypedDict): """Type definition for command argument.""" name: str type: List[str] required: NotRequired[bool] values: NotRequired[List[str]] extra_arg: NotRequired[CommandExtraArg] extension: NotRequired[str] extension_values: NotRequired[Dict[str, str]] # Statement elements (see RFC, section 8.3) # They are used in different commands. comparator: CommandArg = { "name": "comparator", "type": ["tag"], "values": [":comparator"], "extra_arg": {"type": "string", "values": ['"i;octet"', '"i;ascii-casemap"']}, "required": False, } address_part: CommandArg = { "name": "address-part", "values": [":localpart", ":domain", ":all"], "type": ["tag"], "required": False, } match_type: CommandArg = { "name": "match-type", "values": [":is", ":contains", ":matches"], "extension_values": { ":count": "relational", ":value": "relational", ":regex": "regex", }, "extra_arg": { "type": "string", "values": ['"gt"', '"ge"', '"lt"', '"le"', '"eq"', '"ne"'], "valid_for": [":count", ":value"], }, "type": ["tag"], "required": False, } class Command: """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 """ args_definition: List[CommandArg] _type: str variable_args_nb: bool = False non_deterministic_args: bool = False accept_children: bool = False must_follow: Optional[List[str]] = None extension: Optional[str] = None def __init__(self, parent: Optional["Command"] = None): self.parent = parent self.arguments: Dict[str, Any] = {} self.extra_arguments: Dict[str, Any] = {} # to store tag arguments self.children: List[Command] = [] self.nextargpos = 0 self.required_args = -1 self.rargs_cnt = 0 self.curarg: Union[CommandArg, None] = ( None # for arguments that expect an argument :p (ex: :comparator) ) self.name: str = self.__class__.__name__.replace("Command", "") self.name = self.name.lower() self.hash_comments: List[bytes] = [] def __repr__(self): return "%s (type: %s)" % (self.name, self._type) def tosieve(self, indentlevel: int = 0, target=sys.stdout): """Generate the sieve syntax corresponding to this command Recursive method. :param indentlevel: current indentation level :param target: opened file pointer where the content will be printed """ self.__print(self.name, indentlevel, nocr=True, target=target) if self.has_arguments(): for arg in self.args_definition: if not arg["name"] in self.arguments: continue target.write(" ") value = self.arguments[arg["name"]] atype = arg["type"] if "tag" in atype: target.write(value) if arg["name"] in self.extra_arguments: value = self.extra_arguments[arg["name"]] atype = arg["extra_arg"]["type"] target.write(" ") else: continue if type(value) == list: if self.__get_arg_type(arg["name"]) == ["testlist"]: target.write("(") for t in value: t.tosieve(target=target) if value.index(t) != len(value) - 1: target.write(", ") target.write(")") else: target.write( "[{}]".format( ", ".join(['"%s"' % v.strip('"') for v in value]) ) ) continue if isinstance(value, Command): value.tosieve(indentlevel, target=target) continue if "string" in atype: target.write(value) if not value.startswith('"') and not value.startswith("["): target.write("\n") else: target.write(str(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: str, indentlevel: int, nocr: bool = 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: str) -> Optional[List[str]]: """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) -> Optional[List[str]]: """Return the first expected token for this command""" return None def has_arguments(self) -> bool: return len(self.args_definition) != 0 def reassign_arguments(self): """Reassign arguments to proper slots. Should be called when parsing of commands with non deterministic arguments is considered done. """ raise NotImplementedError def dump(self, indentlevel: int = 0, target=sys.stdout): """Display the command Pretty printing of this command and its eventual arguments and children. (recursively) :param indentlevel: integer that indicates indentation level to apply """ self.__print(self, indentlevel, target=target) indentlevel += 4 if self.has_arguments(): for arg in self.args_definition: if not arg["name"] in self.arguments: continue value = self.arguments[arg["name"]] atype = arg["type"] if "tag" in atype: self.__print(str(value), indentlevel, target=target) if arg["name"] in self.extra_arguments: value = self.extra_arguments[arg["name"]] atype = arg["extra_arg"]["type"] else: continue if type(value) == list: if self.__get_arg_type(arg["name"]) == ["testlist"]: for t in value: t.dump(indentlevel, target) else: self.__print( "[" + (",".join(value)) + "]", indentlevel, target=target ) continue if isinstance(value, Command): value.dump(indentlevel, target) continue self.__print(str(value), indentlevel, target=target) for ch in self.children: ch.dump(indentlevel, target) def walk(self) -> Iterator["Command"]: """Walk through commands.""" yield self if self.has_arguments(): for arg in self.args_definition: if not arg["name"] in self.arguments: continue value = self.arguments[arg["name"]] if type(value) == list: if self.__get_arg_type(arg["name"]) == ["testlist"]: for t in value: for node in t.walk(): yield node if isinstance(value, Command): for node in value.walk(): yield node for ch in self.children: for node in ch.walk(): yield node def addchild(self, child: "Command") -> bool: """Add a new child to the command A child corresponds to a command located into a block (this command's block). It can be either an action or a control. :param child: the new child :return: True on succes, False otherwise """ if not self.accept_children: return False self.children += [child] return True def iscomplete( self, atype: Optional[str] = None, avalue: Optional[str] = None ) -> bool: """Check if the command is complete Check if all required arguments have been encountered. For commands that allow an undefined number of arguments, this method always returns False. :return: True if command is complete, False otherwise """ if self.variable_args_nb: return False if self.required_args == -1: self.required_args = 0 for arg in self.args_definition: if arg.get("required", False): self.required_args += 1 return ( self.curarg is None or "extra_arg" not in self.curarg or ( "valid_for" in self.curarg["extra_arg"] and atype and atype in self.curarg["extra_arg"]["type"] and avalue not in self.curarg["extra_arg"]["valid_for"] ) ) and (self.rargs_cnt == self.required_args) def get_type(self) -> str: """Return the command's type""" if self._type is None: raise NotImplementedError return self._type def __is_valid_value_for_arg( self, arg: CommandArg, value: str, check_extension: bool = True ) -> bool: """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 :param value: the value to check :param check_extension: check if value requires an extension :return: True on succes, False otherwise """ if "values" not in arg and "extension_values" not in arg: return True if "values" in arg and value.lower() in arg["values"]: return True if "extension_values" in arg: extension = arg["extension_values"].get(value.lower()) if extension: condition = ( check_extension and extension not in RequireCommand.loaded_extensions ) if condition: raise ExtensionNotLoaded(extension) return True return False def __is_valid_type(self, typ: str, typlist: List[str]) -> bool: """Check if type is valid based on input type list "string" is special because it can be used for stringlist :param typ: the type to check :param typlist: the list of type to check :return: True on success, False otherwise """ typ_is_str = typ == "string" str_list_in_typlist = "stringlist" in typlist return typ in typlist or (typ_is_str and str_list_in_typlist) def check_next_arg( self, atype: str, avalue: str, add: bool = True, check_extension: bool = True ) -> bool: """Argument validity checking This method is usually used by the parser to check if detected argument is allowed for this command. We make a distinction between required and optional arguments. Optional (or tagged) arguments can be provided unordered but not the required ones. A special handling is also done for arguments that require an argument (example: the :comparator argument expects a string argument). The "testlist" type is checked separately as we can't know in advance how many arguments will be provided. If the argument is incorrect, the method raises the appropriate exception, or return False to let the parser handle the exception. :param atype: the argument's type :param avalue: the argument's value :param add: indicates if this argument should be recorded on success :param check_extension: raise ExtensionNotLoaded if extension not loaded :return: True on success, False otherwise """ if not self.has_arguments(): return False if self.iscomplete(atype, avalue): return False if self.curarg is not None and "extra_arg" in self.curarg: condition = atype in self.curarg["extra_arg"]["type"] and ( "values" not in self.curarg["extra_arg"] or avalue in self.curarg["extra_arg"]["values"] ) if condition: if add: self.extra_arguments[self.curarg["name"]] = avalue self.curarg = None return True raise BadValue(self.curarg["name"], avalue) failed = False pos = self.nextargpos while pos < len(self.args_definition): curarg = self.args_definition[pos] if curarg.get("required", False): if curarg["type"] == ["testlist"]: if atype != "test": failed = True elif add: if not curarg["name"] in self.arguments: self.arguments[curarg["name"]] = [] self.arguments[curarg["name"]] += [avalue] elif not self.__is_valid_type( atype, curarg["type"] ) or not self.__is_valid_value_for_arg(curarg, avalue, check_extension): failed = True else: self.curarg = curarg self.rargs_cnt += 1 self.nextargpos = pos + 1 if add: self.arguments[curarg["name"]] = avalue break condition: bool = atype in curarg["type"] and self.__is_valid_value_for_arg( curarg, avalue, check_extension ) if condition: ext = curarg.get("extension") condition = ( check_extension and ext and ext not in RequireCommand.loaded_extensions ) if condition: raise ExtensionNotLoaded(ext) condition = "extra_arg" in curarg and ( "valid_for" not in curarg["extra_arg"] or avalue in curarg["extra_arg"]["valid_for"] ) if condition: self.curarg = curarg if add: self.arguments[curarg["name"]] = avalue break pos += 1 if failed: raise BadArgument(self.name, avalue, self.args_definition[pos]["type"]) return True def __contains__(self, name: str) -> bool: """Check if argument is provided with command.""" return name in self.arguments def __getitem__(self, name: str) -> Any: """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: List[str] = [] def complete_cb(self): if type(self.arguments["capabilities"]) != list: exts = [self.arguments["capabilities"]] else: exts = self.arguments["capabilities"] for ext in exts: ext = ext.strip('"') if ext not in RequireCommand.loaded_extensions: RequireCommand.loaded_extensions += [ext] class IfCommand(ControlCommand): accept_children = True args_definition = [{"name": "test", "type": ["test"], "required": True}] def get_expected_first(self) -> List[str]: 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) -> List[str]: return ["identifier"] class ElseCommand(ControlCommand): accept_children = True must_follow = ["if", "elsif"] args_definition = [] class ActionCommand(Command): """Indermediate class to represent "action" commands""" _type = "action" def args_as_tuple(self): args = [] for name, value in list(self.arguments.items()): unquote = False for argdef in self.args_definition: if name == argdef["name"]: condition = ( "string" in argdef["type"] or "stringlist" in argdef["type"] ) if condition: unquote = True break if unquote: if "," in value: args += tools.to_list(value) else: args.append(value.strip('"')) continue args.append(value) return (self.name,) + tuple(args) class StopCommand(ActionCommand): args_definition = [] class FileintoCommand(ActionCommand): extension = "fileinto" args_definition = [ { "name": "copy", "type": ["tag"], "values": [":copy"], "required": False, "extension": "copy", }, { "name": "create", "type": ["tag"], "values": [":create"], "required": False, "extension": "mailbox", }, { "name": "flags", "type": ["tag"], "values": [":flags"], "extra_arg": {"type": ["string", "stringlist"]}, "extension": "imap4flags", }, {"name": "mailbox", "type": ["string"], "required": True}, ] class RedirectCommand(ActionCommand): args_definition = [ { "name": "copy", "type": ["tag"], "values": [":copy"], "required": False, "extension": "copy", }, {"name": "address", "type": ["string"], "required": True}, ] class RejectCommand(ActionCommand): extension = "reject" args_definition = [{"name": "text", "type": ["string"], "required": True}] class KeepCommand(ActionCommand): args_definition = [ { "name": "flags", "type": ["tag"], "values": [":flags"], "extra_arg": {"type": ["string", "stringlist"]}, "extension": "imap4flags", }, ] class DiscardCommand(ActionCommand): args_definition = [] class SetflagCommand(ActionCommand): """imap4flags extension: setflag.""" args_definition = [ {"name": "variable-name", "type": ["string"], "required": False}, {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True}, ] extension = "imap4flags" class AddflagCommand(ActionCommand): """imap4flags extension: addflag.""" args_definition = [ {"name": "variable-name", "type": ["string"], "required": False}, {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True}, ] extension = "imap4flags" class RemoveflagCommand(ActionCommand): """imap4flags extension: removeflag.""" args_definition = [ {"name": "variable-name", "type": ["string"]}, {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True}, ] extension = "imap4flags" class TestCommand(Command): """Indermediate class to represent "test" commands""" _type = "test" class AddressCommand(TestCommand): args_definition = [ comparator, address_part, match_type, {"name": "header-list", "type": ["string", "stringlist"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True}, ] class AllofCommand(TestCommand): accept_children = True variable_args_nb = True args_definition = [{"name": "tests", "type": ["testlist"], "required": True}] def get_expected_first(self) -> List[str]: 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) -> List[str]: return ["left_parenthesis"] class EnvelopeCommand(TestCommand): args_definition = [ comparator, address_part, match_type, {"name": "header-list", "type": ["string", "stringlist"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True}, ] extension = "envelope" def args_as_tuple(self): """Return arguments as a list.""" result = ("envelope", self.arguments["match-type"]) value = self.arguments["header-list"] if isinstance(value, list): # FIXME value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) if value.startswith("["): result += (tools.to_list(value),) else: result += ([value.strip('"')],) value = self.arguments["key-list"] if isinstance(value, list): # FIXME value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) if value.startswith("["): result += (tools.to_list(value),) else: result = result + ([value.strip('"')],) return result class ExistsCommand(TestCommand): args_definition = [ {"name": "header-names", "type": ["string", "stringlist"], "required": True} ] def args_as_tuple(self): """FIXME: en fonction de la manière dont la commande a été générée (factory ou parser), le type des arguments est différent : string quand ça vient de la factory ou type normal depuis le parser. Il faut uniformiser tout ça !! """ value = self.arguments["header-names"] if isinstance(value, list): value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) if not value.startswith("["): return ("exists", value.strip('"')) return ("exists",) + tuple(tools.to_list(value)) class TrueCommand(TestCommand): args_definition = [] class FalseCommand(TestCommand): args_definition = [] class HeaderCommand(TestCommand): args_definition = [ comparator, match_type, {"name": "header-names", "type": ["string", "stringlist"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True}, ] def args_as_tuple(self): """Return arguments as a list.""" if "," in self.arguments["header-names"]: result = tuple(tools.to_list(self.arguments["header-names"])) else: result = (self.arguments["header-names"].strip('"'),) result = result + (self.arguments["match-type"],) if "," in self.arguments["key-list"]: result = result + tuple( tools.to_list(self.arguments["key-list"], unquote=False) ) else: result = result + (self.arguments["key-list"].strip('"'),) return result class BodyCommand(TestCommand): """Body extension. See https://tools.ietf.org/html/rfc5173. """ args_definition = [ comparator, match_type, { "name": "body-transform", "values": [":raw", ":content", ":text"], "extra_arg": {"type": "stringlist", "valid_for": [":content"]}, "type": ["tag"], "required": False, }, {"name": "key-list", "type": ["string", "stringlist"], "required": True}, ] extension = "body" def args_as_tuple(self): """Return arguments as a list.""" result = ("body",) result = result + ( self.arguments["body-transform"], self.arguments["match-type"], ) value = self.arguments["key-list"] if isinstance(value, list): # FIXME value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) if value.startswith("["): result += tuple(tools.to_list(value)) else: result += (value.strip('"'),) return result class NotCommand(TestCommand): accept_children = True args_definition = [{"name": "test", "type": ["test"], "required": True}] def get_expected_first(self): return ["identifier"] class SizeCommand(TestCommand): args_definition = [ { "name": "comparator", "type": ["tag"], "values": [":over", ":under"], "required": True, }, {"name": "limit", "type": ["number"], "required": True}, ] def args_as_tuple(self): return ("size", self.arguments["comparator"], self.arguments["limit"]) class HasflagCommand(TestCommand): """imap4flags extension: hasflag.""" args_definition = [ comparator, match_type, {"name": "variable-list", "type": ["string", "stringlist"], "required": False}, {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True}, ] extension = "imap4flags" non_deterministic_args = True def reassign_arguments(self): """Deal with optional stringlist before a required one.""" condition = ( "variable-list" in self.arguments and "list-of-flags" not in self.arguments ) if condition: self.arguments["list-of-flags"] = self.arguments.pop("variable-list") self.rargs_cnt = 1 class DateCommand(TestCommand): """date command, part of the date extension. https://tools.ietf.org/html/rfc5260#section-4 """ extension = "date" args_definition = [ { "name": "zone", "type": ["tag"], "values": [":zone", ":originalzone"], "extra_arg": {"type": "string", "valid_for": [":zone"]}, "required": False, }, comparator, match_type, {"name": "header-name", "type": ["string"], "required": True}, {"name": "date-part", "type": ["string"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True}, ] class CurrentdateCommand(TestCommand): """currentdate command, part of the date extension. http://tools.ietf.org/html/rfc5260#section-5 """ extension = "date" args_definition = [ { "name": "zone", "type": ["tag"], "values": [":zone"], "extra_arg": {"type": "string"}, "required": False, }, comparator, match_type, {"name": "date-part", "type": ["string"], "required": True}, {"name": "key-list", "type": ["string", "stringlist"], "required": True}, ] def args_as_tuple(self): """Return arguments as a list.""" result = ("currentdate",) result += ( ":zone", self.extra_arguments["zone"].strip('"'), self.arguments["match-type"], ) if self.arguments["match-type"] in [":count", ":value"]: result += (self.extra_arguments["match-type"].strip('"'),) result += (self.arguments["date-part"].strip('"'),) value = self.arguments["key-list"] if isinstance(value, list): # FIXME value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) if value.startswith("["): result = result + tuple(tools.to_list(value)) else: result = result + (value.strip('"'),) return result class VacationCommand(ActionCommand): extension = "vacation" args_definition = [ { "name": "subject", "type": ["tag"], "values": [":subject"], "extra_arg": {"type": "string"}, "required": False, }, { "name": "days", "type": ["tag"], "values": [":days"], "extra_arg": {"type": "number"}, "required": False, }, { "name": "seconds", "type": ["tag"], "extension_values": {":seconds": "vacation-seconds"}, "extra_arg": {"type": "number"}, "required": False, }, { "name": "from", "type": ["tag"], "values": [":from"], "extra_arg": {"type": "string"}, "required": False, }, { "name": "addresses", "type": ["tag"], "values": [":addresses"], "extra_arg": {"type": ["string", "stringlist"]}, "required": False, }, { "name": "handle", "type": ["tag"], "values": [":handle"], "extra_arg": {"type": "string"}, "required": False, }, {"name": "mime", "type": ["tag"], "values": [":mime"], "required": False}, {"name": "reason", "type": ["string"], "required": True}, ] class SetCommand(ControlCommand): """set command, part of the variables extension http://tools.ietf.org/html/rfc5229 """ extension = "variables" args_definition = [ {"name": "startend", "type": ["string"], "required": True}, {"name": "date", "type": ["string"], "required": True}, ] def add_commands(cmds): """ Adds one or more commands to the module namespace. Commands must end in "Command" to be added. Example (see tests/parser.py): sievelib.commands.add_commands(MytestCommand) :param cmds: a single Command Object or list of Command Objects """ if not isinstance(cmds, Iterable): cmds = [cmds] for command in cmds: if command.__name__.endswith("Command"): globals()[command.__name__] = command def get_command_instance( name: str, parent: Optional[Command] = None, checkexists: bool = True ) -> Command: """Try to guess and create the appropriate command instance Given a command name (encountered by the parser), construct the associated class name and, if known, return a new instance. If the command is not known or has not been loaded using require, an UnknownCommand exception is raised. :param name: the command's name :param parent: the eventual parent command :return: a new class instance """ cname = "%sCommand" % name.lower().capitalize() gl = globals() condition = cname not in gl if condition: raise UnknownCommand(name) condition = ( checkexists and gl[cname].extension and gl[cname].extension not in RequireCommand.loaded_extensions ) if condition: raise ExtensionNotLoaded(gl[cname].extension) return gl[cname](parent) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/digest_md5.py0000644000175100001660000000507614737442253017146 0ustar00runnerdocker""" 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) ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/factory.py0000644000175100001660000005401614737442253016567 0ustar00runnerdocker""" Tools for simpler sieve filters generation. This module is intented to facilitate the creation of sieve filters without having to write or to know the syntax. Only commands (control/test/action) defined in the ``commands`` module are supported. """ import io import sys from typing import List, Optional, TypedDict, Union from typing_extensions import NotRequired from sievelib import commands class FilterAlreadyExists(Exception): pass class Filter(TypedDict): """Type definition for filter.""" name: str content: commands.Command enabled: bool description: NotRequired[str] class FiltersSet: """A set of filters.""" def __init__( self, name: str, filter_name_pretext: str = "# Filter: ", filter_desc_pretext: str = "# 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: List[str] = [] self.filters: List[Filter] = [] def __str__(self): target = io.StringIO() self.tosieve(target) ret = target.getvalue() target.close() return ret def __isdisabled(self, fcontent: commands.Command) -> bool: """Tells if a filter is disabled or not Simply checks if the filter is surrounded by a "if false" test. :param fcontent: the filter's name """ if not isinstance(fcontent, commands.IfCommand): return False if not isinstance(fcontent["test"], commands.FalseCommand): return False return True def from_parser_result(self, parser: "sievelib.parser.Parser"): cpt = 1 for f in parser.result: if isinstance(f, commands.RequireCommand): if type(f.arguments["capabilities"]) == list: [self.require(c) for c in f.arguments["capabilities"]] else: self.require(f.arguments["capabilities"]) continue name = "Unnamed rule %d" % cpt description = "" for comment in f.hash_comments: if isinstance(comment, bytes): comment = comment.decode("utf-8") if comment.startswith(self.filter_name_pretext): name = comment.replace(self.filter_name_pretext, "") if comment.startswith(self.filter_desc_pretext): description = comment.replace(self.filter_desc_pretext, "") self.filters += [ { "name": name, "description": description, "content": f, "enabled": not self.__isdisabled(f), } ] cpt += 1 def require(self, name: str): """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: str): """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) -> Union[commands.Command, None]: """Internal method to create a RequireCommand based on requirements Called just before this object is going to be dumped. """ if not len(self.requires): return None reqcmd = commands.get_command_instance("require") reqcmd.check_next_arg("stringlist", self.requires) return reqcmd def __quote_if_necessary(self, value: str) -> str: """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: List[str], parent: commands.Command, tag: Optional[str] = None ) -> commands.Command: """Translate a condition to a valid sievelib Command. :param list condition: condition's definition :param ``Command`` parent: the parent :param str tag: tag to use instead of the one included into :keyword:`condition` :rtype: Command :return: the generated command """ if tag is None: tag = condition[1] cmd = commands.get_command_instance("header", parent) cmd.check_next_arg("tag", tag) if isinstance(condition[0], list): cmd.check_next_arg("stringlist", [self.__quote_if_necessary(c) for c in condition[0]]) else: cmd.check_next_arg("string", self.__quote_if_necessary(condition[0])) if isinstance(condition[2], list): cmd.check_next_arg("stringlist", [self.__quote_if_necessary(c) for c in condition[2]]) else: cmd.check_next_arg("string", self.__quote_if_necessary(condition[2])) return cmd def __create_filter( self, conditions: List[tuple], actions: List[tuple], matchtype: str = "anyof", ) -> commands.Command: """Create a new filter A filter is composed of: * a name * one or more conditions (tests) combined together using ``matchtype`` * one or more actions A condition must be given as a 3-uple of the form:: (test's name, operator, value) An action must be given as a 2-uple of the form:: (action's name, value) It uses the "header" test to generate the sieve syntax corresponding to the given conditions. :param conditions: the list of conditions :param actions: the list of actions :param matchtype: "anyof" or "allof" """ ifcontrol = commands.get_command_instance("if") mtypeobj = commands.get_command_instance(matchtype, ifcontrol) for c in conditions: if not isinstance(c[0], list) and c[0].startswith("not"): negate = True cname = c[0].replace("not", "", 1) else: negate = False cname = c[0] if cname in ("true", "false"): cmd = commands.get_command_instance(c[0], ifcontrol) elif cname == "size": cmd = commands.get_command_instance("size", ifcontrol) cmd.check_next_arg("tag", c[1]) cmd.check_next_arg("number", c[2]) elif cname == "exists": cmd = commands.get_command_instance("exists", ifcontrol) cmd.check_next_arg( "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[1:])) ) elif cname == "envelope": cmd = commands.get_command_instance("envelope", ifcontrol, False) self.require("envelope") if c[1].startswith(":not"): comp_tag = c[1].replace("not", "") negate = True else: comp_tag = c[1] cmd.check_next_arg("tag", comp_tag) cmd.check_next_arg( "stringlist", "[{}]".format(",".join('"{}"'.format(val) for val in c[2])), ) cmd.check_next_arg( "stringlist", "[{}]".format(",".join('"{}"'.format(val) for val in c[3])), ) elif cname == "body": cmd = commands.get_command_instance("body", ifcontrol, False) self.require(cmd.extension) cmd.check_next_arg("tag", c[1]) if c[2].startswith(":not"): comp_tag = c[2].replace("not", "") negate = True else: comp_tag = c[2] cmd.check_next_arg("tag", comp_tag) cmd.check_next_arg( "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[3:])) ) elif cname == "currentdate": cmd = commands.get_command_instance("currentdate", ifcontrol, False) self.require(cmd.extension) cmd.check_next_arg("tag", c[1]) cmd.check_next_arg("string", self.__quote_if_necessary(c[2])) if c[3].startswith(":not"): comp_tag = c[3].replace("not", "") negate = True else: comp_tag = c[3] cmd.check_next_arg("tag", comp_tag, check_extension=False) next_arg_pos = 4 if comp_tag == ":value": self.require("relational") cmd.check_next_arg( "string", self.__quote_if_necessary(c[next_arg_pos]) ) next_arg_pos += 1 cmd.check_next_arg("string", self.__quote_if_necessary(c[next_arg_pos])) next_arg_pos += 1 cmd.check_next_arg( "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[next_arg_pos:])), ) else: # header command fallback if c[1].startswith(":not"): cmd = self.__build_condition( c, ifcontrol, c[1].replace("not", "", 1) ) negate = True else: cmd = self.__build_condition(c, ifcontrol) if negate: not_cmd = commands.get_command_instance("not", ifcontrol) not_cmd.check_next_arg("test", cmd) cmd = not_cmd mtypeobj.check_next_arg("test", cmd) ifcontrol.check_next_arg("test", mtypeobj) for actdef in actions: action = commands.get_command_instance(actdef[0], ifcontrol, False) if action.extension is not None: self.require(action.extension) for arg in actdef[1:]: self.check_if_arg_is_extension(arg) if isinstance(arg, int): atype = "number" elif isinstance(arg, list): atype = "stringlist" elif 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) -> str: """Convert name to unicode if necessary.""" return name.decode("utf-8") if isinstance(name, bytes) else name def filter_exists(self, name: str) -> bool: """Check if a filter with name already exists.""" for existing_filter in self.filters: if existing_filter["name"] == name: return True return False def addfilter( self, name: str, conditions: List[tuple], actions: List[tuple], matchtype: str = "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" """ name = self._unicode_filter_name(name) if self.filter_exists(name): raise FilterAlreadyExists ifcontrol = self.__create_filter(conditions, actions, matchtype) self.filters += [ { "name": name, "content": ifcontrol, "enabled": True, } ] def updatefilter( self, oldname: str, newname: str, conditions: List[tuple], actions: List[tuple], matchtype: str = "anyof", ) -> bool: """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" """ filter_def = None oldname = self._unicode_filter_name(oldname) for f in self.filters: if f["name"] == oldname: filter_def = f break if not filter_def: return False newname = self._unicode_filter_name(newname) if newname != oldname and self.filter_exists(newname): raise FilterAlreadyExists filter_def["name"] = newname filter_def["content"] = self.__create_filter(conditions, actions, matchtype) if not filter_def["enabled"]: return self.disablefilter(newname) return True def replacefilter( self, oldname: str, sieve_filter: commands.Command, newname: Optional[str] = None, description: Optional[str] = None, ) -> bool: """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() """ filter_def = None oldname = self._unicode_filter_name(oldname) for f in self.filters: if f["name"] == oldname: filter_def = f break if not filter_def: return False if newname is None: newname = oldname newname = self._unicode_filter_name(newname) if newname != oldname and self.filter_exists(newname): raise FilterAlreadyExists filter_def["name"] = newname filter_def["content"] = sieve_filter if description is not None: filter_def["description"] = description if not filter_def["enabled"]: return self.disablefilter(newname) return True def getfilter(self, name: str) -> Union[commands.Command, None]: """Search for a specific filter :param name: the filter's name :return: the Command object if found, None otherwise """ name = self._unicode_filter_name(name) for f in self.filters: if f["name"] == name: if not f["enabled"]: return f["content"].children[0] return f["content"] return None def get_filter_matchtype(self, name: str) -> Union[str, None]: """Retrieve matchtype of the given filter.""" flt = self.getfilter(name) if not flt: return None for node in flt.walk(): if isinstance(node, (commands.AllofCommand, commands.AnyofCommand)): return node.__class__.__name__.lower().replace("command", "") return None def get_filter_conditions(self, name: str) -> Union[List[str], None]: """Retrieve conditions of the given filter.""" flt = self.getfilter(name) if not flt: return None conditions = [] negate = False for node in flt.walk(): if isinstance(node, commands.NotCommand): negate = True elif isinstance( node, ( commands.HeaderCommand, commands.SizeCommand, commands.ExistsCommand, commands.BodyCommand, commands.EnvelopeCommand, commands.CurrentdateCommand, ), ): args = node.args_as_tuple() if negate: if node.name in ["header", "envelope"]: nargs = (args[0], ":not{}".format(args[1][1:])) if len(args) > 3: nargs += args[2:] else: nargs += (args[2],) args = nargs elif node.name == "body": args = args[:2] + (":not{}".format(args[2][1:]),) + args[3:] elif node.name == "currentdate": args = args[:3] + (":not{}".format(args[3][1:]),) + args[4:] elif node.name == "exists": args = ("not{}".format(args[0]),) + args[1:] negate = False conditions.append(args) return conditions def get_filter_actions(self, name: str) -> Union[List[str], None]: """Retrieve actions of the given filter.""" flt = self.getfilter(name) if not flt: return None actions: list = [] for node in flt.walk(): if isinstance(node, commands.ActionCommand): actions.append(node.args_as_tuple()) return actions def removefilter(self, name: str) -> bool: """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: str) -> bool: """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: str) -> bool: """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: str) -> bool: """Disable a filter Instead of commenting the filter, we just surround it with a "if false { }" test. :param name: the filter's name :return: True if filter was disabled, False otherwise """ name = self._unicode_filter_name(name) ifcontrol = commands.get_command_instance("if") falsecmd = commands.get_command_instance("false", ifcontrol) ifcontrol.check_next_arg("test", falsecmd) for f in self.filters: if f["name"] != name: continue ifcontrol.addchild(f["content"]) f["content"] = ifcontrol f["enabled"] = False return True return False def movefilter(self, name: str, direction: str) -> bool: """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, target=sys.stdout): """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(target=target) target.write("\n") for f in self.filters: target.write("Filter Name: %s\n" % f["name"]) if "description" in f: target.write("Filter Description: %s\n" % f["description"]) f["content"].dump(target=target) 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("\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( "{}{}\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() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/managesieve.py0000644000175100001660000005756114737442253017414 0ustar00runnerdocker""" A MANAGESIEVE client. A protocol for securely managing Sieve scripts on a remote server. This protocol allows a user to have multiple scripts, and also alerts a user to syntactically flawed scripts. Implementation based on RFC 5804. """ import base64 import re import socket import ssl from typing import Any, List, Optional, Tuple from .digest_md5 import DigestMD5 from . import tools CRLF = b"\r\n" KNOWN_CAPABILITIES = [ "IMPLEMENTATION", "SASL", "SIEVE", "STARTTLS", "NOTIFY", "LANGUAGE", "VERSION", ] SUPPORTED_AUTH_MECHS = ["DIGEST-MD5", "PLAIN", "LOGIN", "OAUTHBEARER"] class Error(Exception): pass class Response(Exception): def __init__(self, code: bytes, data: bytes): self.code = code self.data = data def __str__(self): return "%s %s" % (self.code, self.data) class Literal(Exception): def __init__(self, value): self.value = value def __str__(self): return "{%d}" % self.value def authentication_required(meth): """Simple class method decorator. Checks if the client is currently connected. :param meth: the original called method """ def check(cls, *args, **kwargs): if cls.authenticated: return meth(cls, *args, **kwargs) raise Error("Authentication required") return check class Client: read_size = 4096 read_timeout = 5 def __init__(self, srvaddr: str, srvport: int = 4190, debug: bool = False): self.srvaddr = srvaddr self.srvport = srvport self.__debug = debug self.sock: socket.socket = None self.__read_buffer: bytes = b"" self.authenticated: bool = False self.errcode: bytes = None self.errmsg: bytes = b"" self.__capabilities: dict[str, str] = {} self.__respcode_expr = re.compile(rb"(OK|NO|BYE)\s*(.+)?") self.__error_expr = re.compile(rb'(\([\w/-]+\))?\s*(".+")') self.__size_expr = re.compile(rb"\{(\d+)\+?\}") self.__active_expr = re.compile(rb"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: int) -> bytes: """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) -> bytes: """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: int = -1) -> Tuple[bytes, bytes, bytes]: """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: List[Any]) -> List[bytes]: """Format command arguments before sending them. Command arguments of type string must be quoted, the only exception concerns size indication (of the form {\d\+?}). :param args: list of arguments :return: a list for transformed arguments """ ret = [] for a in args: if isinstance(a, bytes): if self.__size_expr.match(a): ret += [a] else: ret += [b'"' + a + b'"'] continue ret += [bytes(str(a).encode("utf-8"))] return ret def __prepare_content(self, content: str) -> bytes: """Format script content before sending it. Script length must be inserted before the content, enclosed in curly braces, separated by CRLF. :param content: script content as str or bytes :return: transformed script as bytes """ bcontent: bytes = content.encode("utf-8") return b"{%d+}%s%s" % (len(bcontent), CRLF, bcontent) def __send_command( self, name: str, args: Optional[List[bytes]] = None, withcontent: bool = False, extralines: Optional[List[bytes]] = None, nblines: int = -1, ) -> Tuple[str, str, bytes]: """Send a command to the server. If args is not empty, we concatenate the given command with the content of this list. If extralines is not empty, they are sent one by one to the server. (CLRF are automatically appended to them) We wait for a response just after the command has been sent. :param name: the command to sent :param args: a list of arguments for this command :param withcontent: tells the function to return the server's response or not :param extralines: a list of extra lines to sent after the command :param nblines: the number of response lines to read (all by default) :returns: a tuple of the form (code, data[, response]) """ tosend = name.encode("utf-8") if args: tosend += b" " + b" ".join(self.__prepare_args(args)) self.__dprint(b"Command: " + tosend) self.sock.sendall(tosend + CRLF) if extralines: for l in extralines: self.sock.sendall(l + CRLF) code, data, content = self.__read_response(nblines) if isinstance(code, bytes): code = code.decode("utf-8") if isinstance(data, bytes): data = data.decode("utf-8") if withcontent: return (code, data, content) return (code, data) def __get_capabilities(self) -> bool: 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: bytes): """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: bytes, password: bytes, authz_id: bytes = b"" ) -> bool: """SASL PLAIN authentication :param login: username :param password: clear password :return: True on success, False otherwise. """ 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: bytes, password: bytes, authz_id: bytes = "" ) -> bool: """SASL LOGIN authentication :param login: username :param password: clear password :return: True on success, False otherwise. """ extralines = [ b'"%s"' % base64.b64encode(login), b'"%s"' % base64.b64encode(password), ] code, data = self.__send_command( "AUTHENTICATE", [b"LOGIN"], extralines=extralines ) if code == "OK": return True return False def _digest_md5_authentication( self, login: bytes, password: bytes, authz_id: bytes = b"" ) -> bool: """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 _oauthbearer_authentication( self, login: bytes, password: bytes, authz_id: bytes = b"" ) -> bool: """ OAUTHBEARER authentication. :param login: username :param password: clear password :return: True on success, False otherwise. """ if isinstance(login, str): login = login.encode("utf-8") if isinstance(password, str): password = password.encode("utf-8") token = b"n,a=" + login + b",\001auth=Bearer " + password + b"\001\001" token = base64.b64encode(token) code, data = self.__send_command("AUTHENTICATE", [b"OAUTHBEARER", token]) if code == "OK": return True return False def __authenticate( self, login: str, password: str, authz_id: str = "", authmech: Optional[str] = None, ) -> bool: """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.encode("utf-8"), password.encode("utf-8"), authz_id.encode("utf-8"), ): self.authenticated = True return True return False self.errmsg = b"No suitable mechanism found" return False def __starttls(self, keyfile=None, certfile=None) -> bool: """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 context = ssl.create_default_context() if certfile is not None: context.load_cert_chain(certfile, keyfile=keyfile) try: # nsock = ssl.wrap_socket(self.sock, keyfile, certfile) nsock = context.wrap_socket(self.sock, server_hostname=self.srvaddr) 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) -> str: """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) -> List[str]: """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) -> bool: """Tells if the server has STARTTLS support or not. It is read from server capabilities. (see the CAPABILITY command) :rtype: boolean """ return "STARTTLS" in self.__capabilities def get_sieve_capabilities(self): """Returns the SIEVE extensions supported by the server. They're read from server capabilities. (see the CAPABILITY command) :rtype: string """ if isinstance(self.__capabilities["SIEVE"], str): self.__capabilities["SIEVE"] = self.__capabilities["SIEVE"].split() return self.__capabilities["SIEVE"] def connect( self, login: str, password: str, authz_id: str = "", starttls: bool = False, authmech: Optional[str] = None, ): """Establish a connection with the server. This function must be used. It read the server capabilities and wraps calls to STARTTLS and AUTHENTICATE commands. :param login: username :param password: clear password :param starttls: use a TLS connection or not :param authmech: prefered authenticate mechanism :rtype: boolean """ try: self.sock = socket.create_connection((self.srvaddr, self.srvport)) self.sock.settimeout(Client.read_timeout) except socket.error as msg: raise Error("Connection to server failed: %s" % str(msg)) if not self.__get_capabilities(): raise Error("Failed to read capabilities from server") if starttls and not self.__starttls(): return False if self.__authenticate(login, password, authz_id, authmech): return True return False def logout(self): """Disconnect from the server See MANAGESIEVE specifications, section 2.3 """ self.__send_command("LOGOUT") def capability(self): """Ask server capabilities. See MANAGESIEVE specifications, section 2.4 This command does not affect capabilities recorded by this client. :rtype: string """ code, data, capabilities = self.__send_command("CAPABILITY", withcontent=True) if code == "OK": return capabilities return None @authentication_required def havespace(self, scriptname: str, scriptsize: int) -> bool: """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) -> Tuple[str, List[str]]: """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: List[str] = [] active_script: str = None for l in listing.splitlines(): if self.__size_expr.match(l): continue m = re.match(rb'"([^"]+)"\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: str) -> str: """ 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 "\n".join([line.decode("utf-8") for line in lines]) return None @authentication_required def putscript(self, name: str, content: str) -> bool: """Upload a script to the server See MANAGESIEVE specifications, section 2.6 :param name: script's name :param content: script's content :rtype: boolean """ bcontent = self.__prepare_content(content) code, data = self.__send_command("PUTSCRIPT", [name.encode("utf-8"), bcontent]) if code == "OK": return True return False @authentication_required def deletescript(self, name: str) -> bool: """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: str, newname: str) -> bool: """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: str) -> bool: """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: str) -> bool: """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") bcontent = self.__prepare_content(content) code, data = self.__send_command("CHECKSCRIPT", [bcontent]) if code == "OK": return True return False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/parser.py0000755000175100001660000004572114737442253016422 0ustar00runnerdocker#!/usr/bin/env python """ This module provides a simple but functional parser for the SIEVE language used to filter emails. This implementation is based on RFC 5228 (http://tools.ietf.org/html/rfc5228) """ import re import sys from typing import Iterator, Tuple from sievelib.commands import get_command_instance, CommandError, RequireCommand class ParseError(Exception): """Generic parsing error""" def __init__(self, msg: str): self.msg = msg def __str__(self): return f"parsing error: {self.msg}" class Lexer: """ The lexical analysis part. This class provides a simple way to define tokens (with patterns) to be detected. Patterns are provided into a list of 2-uple. Each 2-uple consists of a token name and an associated pattern, example: [(b"left_bracket", br'\['),] """ def __init__(self, definitions): self.definitions = definitions parts = [] for name, part in definitions: param = b"(?P<%s>%s)" % (name, part) parts.append(param) self.regexpString = b"|".join(parts) self.regexp = re.compile(self.regexpString, re.MULTILINE) self.wsregexp = re.compile(rb"\s+", re.M) def curlineno(self) -> int: """Return the current line number""" return self.text[: self.pos].count(b"\n") + 1 def curcolno(self) -> int: """Return the current column number""" return self.pos - self.text.rfind(b"\n", 0, self.pos) def scan(self, text: bytes) -> Iterator[Tuple[str, bytes]]: """Analyse some data Analyse the passed content. Each time a token is recognized, a 2-uple containing its name and parsed value is raised (via yield). On error, a ParseError exception is raised. :param text: a binary string containing the data to parse """ self.pos = 0 self.text = text while self.pos < len(text): m = self.wsregexp.match(text, self.pos) if m is not None: self.pos = m.end() continue m = self.regexp.match(text, self.pos) if m is None: token = text[self.pos :] m = self.wsregexp.search(token) if m is not None: token = token[: m.start()] raise ParseError(f"unknown token {token}") yield (m.lastgroup, m.group(m.lastgroup)) self.pos += len(m.group(0)) class Parser: """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", rb"\["), (b"right_bracket", rb"\]"), (b"left_parenthesis", rb"\("), (b"right_parenthesis", rb"\)"), (b"left_cbracket", rb"{"), (b"right_cbracket", rb"}"), (b"semicolon", rb";"), (b"comma", rb","), (b"hash_comment", rb"#.*$"), (b"bracket_comment", rb"/\*[\s\S]*?\*/"), (b"multiline", rb"text:[^$]*?[\r\n]+\.$"), (b"string", rb'"([^"\\]|\\.)*"'), (b"identifier", rb"[a-zA-Z_][\w]*"), (b"tag", rb":[a-zA-Z_][\w]*"), (b"number", rb"[0-9]+[KMGkmg]?"), ] def __init__(self, debug: bool = False): self.debug = debug self.lexer = Lexer(Parser.lrules) def __dprint(self, *msgs): if not self.debug: return for m in msgs: print(m) def __reset_parser(self): """Reset parser's internal variables Restore the parser to an initial state. Useful when creating a new parser or reusing an existing one. """ self.result = [] self.hash_comments = [] self.__cstate = None self.__curcommand = None self.__curstringlist = None self.__expected = None self.__expected_brackets = [] RequireCommand.loaded_extensions = [] def __set_expected(self, *args, **kwargs): """Set the next expected token. One or more tokens can be provided. (they will represent the valid possibilities for the next token). """ self.__expected = args def __push_expected_bracket(self, ttype: str, tvalue: bytes): """Append a new expected bracket. Next time a bracket is closed, it must match the one provided here. """ self.__expected_brackets.append((ttype, tvalue)) def __pop_expected_bracket(self, ttype: str, tvalue): """Drop the last expected bracket. If the given bracket doesn't match the dropped expected bracket, or if no bracket is expected at all, a ParseError will be raised. """ try: etype, evalue = self.__expected_brackets.pop() except IndexError: raise ParseError("unexpected closing bracket %s (none opened)" % (tvalue,)) if ttype != etype: raise ParseError( "unexpected closing bracket %s (expected %s)" % (tvalue, evalue) ) def __up(self, onlyrecord: bool = 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) != 0 else None else: prevcmd = ( self.__curcommand.parent.children[-2] if len(self.__curcommand.parent.children) >= 2 else None ) if prevcmd is None or prevcmd.name not in self.__curcommand.must_follow: raise ParseError( "the %s command must follow an %s command" % ( self.__curcommand.name, " or ".join(self.__curcommand.must_follow), ) ) if not self.__curcommand.parent: # collect current amount of hash comments for later # parsing into names and desciptions self.__curcommand.hash_comments = self.hash_comments self.hash_comments = [] self.result += [self.__curcommand] if onlyrecord: # We are done return while self.__curcommand: self.__curcommand = self.__curcommand.parent if not self.__curcommand: break # Make sure to detect all done tests (including 'not' ones). condition = ( self.__curcommand.get_type() == "test" and self.__curcommand.iscomplete() ) if condition: continue # If we are on a control accepting a test list, next token # must be a comma or a right parenthesis. condition = ( self.__curcommand.get_type() == "test" and self.__curcommand.variable_args_nb ) if condition: self.__set_expected("comma", "right_parenthesis") break def __check_command_completion(self, testsemicolon: bool = True) -> bool: """Check for command(s) completion This function should be called each time a new argument is seen by the parser in order to check a command is complete. As not only one command can be ended when receiving a new argument (nested commands case), we apply the same work to parent commands. :param testsemicolon: if True, indicates that the next expected token must be a semicolon (for commands that need one) :return: True if command is considered as complete, False otherwise. """ if not self.__curcommand.iscomplete(): return True ctype = self.__curcommand.get_type() condition = ctype == "action" or ( ctype == "control" and not self.__curcommand.accept_children ) if condition: if testsemicolon: self.__set_expected("semicolon") return True while self.__curcommand.parent: cmd = self.__curcommand self.__curcommand = self.__curcommand.parent if self.__curcommand.get_type() in ["control", "test"]: if self.__curcommand.iscomplete(): if self.__curcommand.get_type() == "control": self.__set_expected("left_cbracket") break continue if not self.__curcommand.check_next_arg("test", cmd, add=False): return False if not self.__curcommand.iscomplete(): if self.__curcommand.variable_args_nb: self.__set_expected("comma", "right_parenthesis") break return True def __stringlist(self, ttype: str, tvalue: bytes) -> bool: """Specific method to parse the 'string-list' type Syntax: string-list = "[" string *("," string) "]" / string ; if there is only a single string, the brackets ; are optional """ if ttype == "string": self.__curstringlist += [tvalue.decode("utf-8")] self.__set_expected("comma", "right_bracket") return True if ttype == "comma": self.__set_expected("string") return True if ttype == "right_bracket": self.__pop_expected_bracket(ttype, tvalue) self.__curcommand.check_next_arg("stringlist", self.__curstringlist) self.__cstate = self.__arguments return self.__check_command_completion() return False def __argument(self, ttype: str, tvalue: bytes) -> bool: """Argument parsing method This method acts as an entry point for 'argument' parsing. Syntax: string-list / number / tag :param ttype: current token type :param tvalue: current token value :return: False if an error is encountered, True otherwise """ if ttype in ["multiline", "string"]: return self.__curcommand.check_next_arg("string", tvalue.decode("utf-8")) if ttype in ["number", "tag"]: return self.__curcommand.check_next_arg(ttype, tvalue.decode("ascii")) if ttype == "left_bracket": self.__push_expected_bracket("right_bracket", b"}") self.__cstate = self.__stringlist self.__curstringlist = [] self.__set_expected("string") return True condition = ( ttype in ["left_cbracket", "comma"] and self.__curcommand.non_deterministic_args ) if condition: self.__curcommand.reassign_arguments() # rewind lexer self.lexer.pos -= 1 return True return False def __arguments(self, ttype: str, tvalue: bytes) -> bool: """Arguments parsing method Entry point for command arguments parsing. The parser must call this method for each parsed command (either a control, action or test). Syntax: *argument [ test / test-list ] :param ttype: current token type :param tvalue: current token value :return: False if an error is encountered, True otherwise """ if ttype == "identifier": test = get_command_instance(tvalue.decode("ascii"), self.__curcommand) if test.get_type() != "test": raise ParseError( "Expected test command, '{}' found instead".format(test.name) ) self.__curcommand.check_next_arg("test", test) self.__expected = test.get_expected_first() self.__curcommand = test return self.__check_command_completion(testsemicolon=False) if ttype == "left_parenthesis": self.__push_expected_bracket("right_parenthesis", b")") self.__set_expected("identifier") return True if ttype == "comma": self.__set_expected("identifier") return True if ttype == "right_parenthesis": self.__pop_expected_bracket(ttype, tvalue) self.__up() return True if self.__argument(ttype, tvalue): return self.__check_command_completion(testsemicolon=False) return False def __command(self, ttype: str, tvalue: bytes) -> bool: """Command parsing method Entry point for command parsing. Here is expected behaviour: * Handle command beginning if detected, * Call the appropriate sub-method (specified by __cstate) to handle the body, * Handle command ending or block opening if detected. Syntax: identifier arguments (";" / block) :param ttype: current token type :param tvalue: current token value :return: False if an error is encountered, True otherwise """ if self.__cstate is None: if ttype == "right_cbracket": self.__pop_expected_bracket(ttype, tvalue) self.__up() self.__cstate = None return True if ttype != "identifier": return False command = get_command_instance(tvalue.decode("ascii"), self.__curcommand) if command.get_type() == "test": raise ParseError("%s may not appear as a first command" % command.name) if ( command.get_type() == "control" and command.accept_children and command.has_arguments() ): self.__set_expected("identifier") if self.__curcommand is not None: if not self.__curcommand.addchild(command): raise ParseError( "%s unexpected after a %s" % (tvalue, self.__curcommand.name) ) self.__curcommand = command self.__cstate = self.__arguments return True if self.__cstate(ttype, tvalue): return True if ttype == "left_cbracket": self.__push_expected_bracket("right_cbracket", b"}") self.__cstate = None return True if ttype == "semicolon": self.__cstate = None if not self.__check_command_completion(testsemicolon=False): return False self.__curcommand.complete_cb() self.__up() return True return False def parse(self, text: bytes) -> bool: """The parser entry point. Parse the provided text to check for its validity. On success, the parsing tree is available into the result attribute. It is a list of sievecommands.Command objects (see the module documentation for specific information). On error, an string containing the explicit reason is available into the error attribute. :param text: a string containing the data to parse :return: True on success (no error detected), False otherwise """ if isinstance(text, str): text = text.encode("utf-8") self.__reset_parser() try: ttype: str tvalue: bytes = b"" for ttype, tvalue in self.lexer.scan(text): if ttype == "hash_comment": self.hash_comments += [tvalue.strip()] continue if ttype == "bracket_comment": continue if self.__expected is not None: if ttype not in self.__expected: if self.lexer.pos < len(text) + len(tvalue): msg = "{} found while {} expected near '{}'".format( ttype, "|".join(self.__expected), text.decode()[self.lexer.pos], ) else: msg = "%s found while %s expected at end of file" % ( ttype, "|".join(self.__expected), ) raise ParseError(msg) self.__expected = None if not self.__command(ttype, tvalue): msg = "unexpected token '%s' found near '%s'" % ( tvalue.decode(), text.decode()[self.lexer.pos], ) raise ParseError(msg) if self.__expected_brackets: self.__set_expected(self.__expected_brackets[-1][0]) if self.__expected is not None: raise ParseError( "end of script reached while %s expected" % "|".join(self.__expected) ) except (ParseError, CommandError) as e: self.error_pos = ( self.lexer.curlineno(), self.lexer.curcolno(), len(tvalue), ) self.error = "line %d: %s" % (self.error_pos[0], str(e)) return False return True def parse_file(self, name: str) -> bool: """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__": import argparse parser = argparse.ArgumentParser() parser.add_argument( "-v", "--verbose", action="store_true", default=False, help="Activate verbose mode", ) parser.add_argument( "-d", "--debug", action="store_true", default=False, help="Activate debug traces", ) parser.add_argument( "--tosieve", action="store_true", help="Print parser results using sieve" ) parser.add_argument("files", type=str, nargs="+", help="Files to parse") args = parser.parse_args() for fname in args.files: p = Parser(debug=args.debug) print(f"Parsing file {fname}... ", end=" ") if p.parse_file(fname): print("OK") if args.verbose: p.dump() if args.tosieve: for r in p.result: r.tosieve() continue print("ERROR") print(p.error) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7439578 sievelib-1.4.2/sievelib/tests/0000755000175100001660000000000014737442260015700 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/tests/__init__.py0000644000175100001660000000000014737442253020001 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7449577 sievelib-1.4.2/sievelib/tests/files/0000755000175100001660000000000014737442260017002 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/tests/files/utf8_sieve.txt0000644000175100001660000000026614737442253021632 0ustar00runnerdockerrequire ["fileinto", "reject"]; # Filter: UTF8 Test Filter äöüß 汉语/漢語 Hànyǔ if allof (header :contains ["Subject"] ["€ 300"]) { fileinto "Spam"; stop; } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/tests/test_factory.py0000644000175100001660000004311414737442253020765 0ustar00runnerdockerimport unittest import io from sievelib.factory import FilterAlreadyExists, FiltersSet from .. import parser class FactoryTestCase(unittest.TestCase): def setUp(self): self.fs = FiltersSet("test") def test_add_duplicate_filter(self): """Try to add the same filter name twice, should fail.""" self.fs.addfilter( "ruleX", [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", ":copy", "Toto"), ], ) with self.assertRaises(FilterAlreadyExists): self.fs.addfilter( "ruleX", [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", ":copy", "Toto"), ], ) def test_updatefilter(self): self.fs.addfilter( "ruleX", [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", ":copy", "Toto"), ], ) result = self.fs.updatefilter( "ruleY", "ruleX", [ ("Sender", ":is", "tata@toto.com"), ], [ ("fileinto", ":copy", "Tata"), ], ) self.assertFalse(result) result = self.fs.updatefilter( "ruleX", "ruleY", [ ("Sender", ":is", "tata@toto.com"), ], [ ("fileinto", ":copy", "Tata"), ], ) self.assertTrue(result) self.assertIs(self.fs.getfilter("ruleX"), None) self.assertIsNot(self.fs.getfilter("ruleY"), None) def test_updatefilter_duplicate(self): self.fs.addfilter( "ruleX", [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", ":copy", "Toto"), ], ) self.fs.addfilter( "ruleY", [ ("Sender", ":is", "toto@tota.com"), ], [ ("fileinto", ":copy", "Tota"), ], ) with self.assertRaises(FilterAlreadyExists): self.fs.updatefilter( "ruleX", "ruleY", [ ("Sender", ":is", "toto@toti.com"), ], [ ("fileinto", ":copy", "Toti"), ], ) def test_replacefilter(self): self.fs.addfilter( "ruleX", [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", ":copy", "Toto"), ], ) self.fs.addfilter( "ruleY", [ ("Sender", ":is", "toto@tota.com"), ], [ ("fileinto", ":copy", "Tota"), ], ) content = self.fs.getfilter("ruleX") result = self.fs.replacefilter("ruleZ", content) self.assertFalse(result) result = self.fs.replacefilter("ruleY", content) self.assertTrue(result) def test_get_filter_conditions(self): """Test get_filter_conditions method.""" orig_conditions = [("Sender", ":is", "toto@toto.com")] self.fs.addfilter( "ruleX", orig_conditions, [ ("fileinto", ":copy", "Toto"), ], ) conditions = self.fs.get_filter_conditions("ruleX") self.assertEqual(orig_conditions, conditions) orig_conditions = [ ("exists", "list-help", "list-unsubscribe", "list-subscribe", "list-owner") ] self.fs.addfilter("ruleY", orig_conditions, [("fileinto", "List")]) conditions = self.fs.get_filter_conditions("ruleY") self.assertEqual(orig_conditions, conditions) orig_conditions = [("Sender", ":notis", "toto@toto.com")] self.fs.addfilter( "ruleZ", orig_conditions, [ ("fileinto", ":copy", "Toto"), ], ) conditions = self.fs.get_filter_conditions("ruleZ") self.assertEqual(orig_conditions, conditions) orig_conditions = [ ( "notexists", "list-help", "list-unsubscribe", "list-subscribe", "list-owner", ) ] self.fs.addfilter("ruleA", orig_conditions, [("fileinto", "List")]) conditions = self.fs.get_filter_conditions("ruleA") self.assertEqual(orig_conditions, conditions) orig_conditions = [("envelope", ":is", ["From"], ["hello"])] self.fs.addfilter("ruleB", orig_conditions, [("fileinto", "INBOX")]) conditions = self.fs.get_filter_conditions("ruleB") self.assertEqual(orig_conditions, conditions) orig_conditions = [("body", ":raw", ":notcontains", "matteo")] self.fs.addfilter("ruleC", orig_conditions, [("fileinto", "INBOX")]) conditions = self.fs.get_filter_conditions("ruleC") self.assertEqual(orig_conditions, conditions) orig_conditions = [ ("currentdate", ":zone", "+0100", ":notis", "date", "2019-02-26") ] self.fs.addfilter("ruleD", orig_conditions, [("fileinto", "INBOX")]) conditions = self.fs.get_filter_conditions("ruleD") self.assertEqual(orig_conditions, conditions) orig_conditions = [ ("currentdate", ":zone", "+0100", ":value", "gt", "date", "2019-02-26") ] self.fs.addfilter("ruleE", orig_conditions, [("fileinto", "INBOX")]) conditions = self.fs.get_filter_conditions("ruleE") self.assertEqual(orig_conditions, conditions) def test_get_filter_conditions_from_parser_result(self): res = """require ["fileinto"]; # rule:[test] if anyof (exists ["Subject"]) { fileinto "INBOX"; } """ p = parser.Parser() p.parse(res) fs = FiltersSet("test", "# rule:") fs.from_parser_result(p) c = fs.get_filter_conditions("[test]") self.assertEqual(c, [("exists", "Subject")]) res = """require ["date", "fileinto"]; # rule:aaa if anyof (currentdate :zone "+0100" :is "date" ["2019-03-27"]) { fileinto "INBOX"; } """ p = parser.Parser() p.parse(res) fs = FiltersSet("aaa", "# rule:") fs.from_parser_result(p) c = fs.get_filter_conditions("aaa") self.assertEqual( c, [("currentdate", ":zone", "+0100", ":is", "date", "2019-03-27")] ) res = """require ["envelope", "fileinto"]; # rule:[aaa] if anyof (envelope :contains ["To"] ["hello@world.it"]) { fileinto "INBOX"; } """ p = parser.Parser() p.parse(res) fs = FiltersSet("aaa", "# rule:") fs.from_parser_result(p) c = fs.get_filter_conditions("[aaa]") self.assertEqual(c, [("envelope", ":contains", ["To"], ["hello@world.it"])]) def test_get_filter_matchtype(self): """Test get_filter_matchtype method.""" self.fs.addfilter( "ruleX", [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", ":copy", "Toto"), ], ) match_type = self.fs.get_filter_matchtype("ruleX") self.assertEqual(match_type, "anyof") def test_get_filter_actions(self): """Test get_filter_actions method.""" self.fs.addfilter( "ruleX", [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", ":copy", "Toto"), ], ) actions = self.fs.get_filter_actions("ruleX") self.assertIn("fileinto", actions[0]) self.assertIn(":copy", actions[0]) self.assertIn("Toto", actions[0]) self.fs.addfilter("ruleY", [("Subject", ":contains", "aaa")], [("stop",)]) actions = self.fs.get_filter_actions("ruleY") self.assertIn("stop", actions[0]) def test_add_header_filter(self): output = io.StringIO() self.fs.addfilter( "rule1", [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", ":copy", "Toto"), ], ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual( output.getvalue(), """require ["fileinto", "copy"]; # Filter: rule1 if anyof (header :is "Sender" "toto@toto.com") { fileinto :copy "Toto"; } """, ) output.close() def test_use_action_with_tag(self): output = io.StringIO() self.fs.addfilter( "rule1", [ ("Sender", ":is", "toto@toto.com"), ], [ ("redirect", ":copy", "toto@titi.com"), ], ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual( output.getvalue(), """require ["copy"]; # Filter: rule1 if anyof (header :is "Sender" "toto@toto.com") { redirect :copy "toto@titi.com"; } """, ) output.close() def test_add_header_filter_with_not(self): output = io.StringIO() self.fs.addfilter( "rule1", [("Sender", ":notcontains", "toto@toto.com")], [("fileinto", "Toto")], ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual( output.getvalue(), """require ["fileinto"]; # Filter: rule1 if anyof (not header :contains "Sender" "toto@toto.com") { fileinto "Toto"; } """, ) def test_add_exists_filter(self): output = io.StringIO() self.fs.addfilter( "rule1", [ ( "exists", "list-help", "list-unsubscribe", "list-subscribe", "list-owner", ) ], [("fileinto", "Toto")], ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual( output.getvalue(), """require ["fileinto"]; # Filter: rule1 if anyof (exists ["list-help","list-unsubscribe","list-subscribe","list-owner"]) { fileinto "Toto"; } """, ) def test_add_exists_filter_with_not(self): output = io.StringIO() self.fs.addfilter( "rule1", [ ( "notexists", "list-help", "list-unsubscribe", "list-subscribe", "list-owner", ) ], [("fileinto", "Toto")], ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual( output.getvalue(), """require ["fileinto"]; # Filter: rule1 if anyof (not exists ["list-help","list-unsubscribe","list-subscribe","list-owner"]) { fileinto "Toto"; } """, ) def test_add_size_filter(self): output = io.StringIO() self.fs.addfilter( "rule1", [("size", ":over", "100k")], [("fileinto", "Totoéé")] ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.fs.tosieve(output) self.assertEqual( output.getvalue(), """require ["fileinto"]; # Filter: rule1 if anyof (size :over 100k) { fileinto "Totoéé"; } """, ) def test_remove_filter(self): self.fs.addfilter( "rule1", [("Sender", ":is", "toto@toto.com")], [("fileinto", "Toto")] ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.assertEqual(self.fs.removefilter("rule1"), True) self.assertIs(self.fs.getfilter("rule1"), None) def test_disablefilter(self): """ FIXME: Extra spaces are written between if and anyof, why?! """ self.fs.addfilter( "rule1", [("Sender", ":is", "toto@toto.com")], [("fileinto", "Toto")] ) self.assertIsNot(self.fs.getfilter("rule1"), None) self.assertEqual(self.fs.disablefilter("rule1"), True) output = io.StringIO() self.fs.tosieve(output) self.assertEqual( output.getvalue(), """require ["fileinto"]; # Filter: rule1 if false { if anyof (header :is "Sender" "toto@toto.com") { fileinto "Toto"; } } """, ) output.close() self.assertEqual(self.fs.is_filter_disabled("rule1"), True) def test_add_filter_unicode(self): """Add a filter containing unicode data.""" name = "Test\xe9".encode("utf-8") self.fs.addfilter( name, [ ("Sender", ":is", "toto@toto.com"), ], [ ("fileinto", "Toto"), ], ) self.assertIsNot(self.fs.getfilter("Testé"), None) self.assertEqual( "{}".format(self.fs), """require ["fileinto"]; # Filter: Testé if anyof (header :is "Sender" "toto@toto.com") { fileinto "Toto"; } """, ) def test_add_body_filter(self): """Add a body filter.""" self.fs.addfilter( "test", [("body", ":raw", ":contains", "matteo")], [("fileinto", "Toto")] ) self.assertEqual( "{}".format(self.fs), """require ["body", "fileinto"]; # Filter: test if anyof (body :contains :raw ["matteo"]) { fileinto "Toto"; } """, ) def test_add_notbody_filter(self): """Add a not body filter.""" self.fs.addfilter( "test", [("body", ":raw", ":notcontains", "matteo")], [("fileinto", "Toto")] ) self.assertEqual( "{}".format(self.fs), """require ["body", "fileinto"]; # Filter: test if anyof (not body :contains :raw ["matteo"]) { fileinto "Toto"; } """, ) def test_add_envelope_filter(self): """Add a envelope filter.""" self.fs.addfilter( "test", [("envelope", ":is", ["From"], ["hello"])], [("fileinto", "INBOX")] ) self.assertEqual( "{}".format(self.fs), """require ["envelope", "fileinto"]; # Filter: test if anyof (envelope :is ["From"] ["hello"]) { fileinto "INBOX"; } """, ) def test_add_notenvelope_filter(self): """Add a not envelope filter.""" self.fs.addfilter( "test", [("envelope", ":notis", ["From"], ["hello"])], [("fileinto", "INBOX")], ) self.assertEqual( "{}".format(self.fs), """require ["envelope", "fileinto"]; # Filter: test if anyof (not envelope :is ["From"] ["hello"]) { fileinto "INBOX"; } """, ) def test_add_currentdate_filter(self): """Add a currentdate filter.""" self.fs.addfilter( "test", [("currentdate", ":zone", "+0100", ":is", "date", "2019-02-26")], [("fileinto", "INBOX")], ) self.assertEqual( "{}".format(self.fs), """require ["date", "fileinto"]; # Filter: test if anyof (currentdate :zone "+0100" :is "date" ["2019-02-26"]) { fileinto "INBOX"; } """, ) self.fs.removefilter("test") self.fs.addfilter( "test", [("currentdate", ":zone", "+0100", ":value", "gt", "date", "2019-02-26")], [("fileinto", "INBOX")], ) self.assertEqual( "{}".format(self.fs), """require ["date", "fileinto", "relational"]; # Filter: test if anyof (currentdate :zone "+0100" :value "gt" "date" ["2019-02-26"]) { fileinto "INBOX"; } """, ) def test_vacation(self): self.fs.addfilter( "test", [("Subject", ":matches", "*")], [("vacation", ":subject", "Example Autoresponder Subject", ":days", 7, ":mime", "Example Autoresponder Body")], ) output = io.StringIO() self.fs.tosieve(output) self.assertEqual( output.getvalue(), """require ["vacation"]; # Filter: test if anyof (header :matches "Subject" "*") { vacation :subject "Example Autoresponder Subject" :days 7 :mime "Example Autoresponder Body"; } """, ) def test_dump(self): self.fs.addfilter( "test", [("Subject", ":matches", "*")], [("vacation", ":subject", "Example Autoresponder Subject", ":days", 7, ":mime", "Example Autoresponder Body")] ) output = io.StringIO() self.fs.dump(output) self.assertEqual( output.getvalue(), """require (type: control) [vacation] Filter Name: test if (type: control) anyof (type: test) header (type: test) :matches "Subject" "*" vacation (type: action) :subject "Example Autoresponder Subject" :days 7 :mime "Example Autoresponder Body" """, ) def test_stringlist_condition(self): self.fs.addfilter( "test", [(["X-Foo", "X-Bar"], ":contains", ["bar", "baz"])], [], ) output = io.StringIO() self.fs.tosieve(output) self.assertEqual( output.getvalue(), """# Filter: test if anyof (header :contains ["X-Foo", "X-Bar"] ["bar", "baz"]) { } """ ) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/tests/test_managesieve.py0000644000175100001660000001302214737442253021575 0ustar00runnerdocker"""Managesieve test cases.""" import unittest from unittest import mock from sievelib import managesieve CAPABILITIES = ( b'"IMPLEMENTATION" "Example1 ManageSieved v001"\r\n' b'"VERSION" "1.0"\r\n' b'"SASL" "PLAIN SCRAM-SHA-1 GSSAPI OAUTHBEARER"\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 OAUTHBEARER"\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("user", "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_auth_oauthbearer(self, mock_socket): """Test OAUTHBEARER mechanism.""" mock_socket.return_value.recv.side_effect = (AUTHENTICATION,) self.assertTrue(self.client.connect("user", "token", authmech="OAUTHBEARER")) 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, ["summer_script", "vacàtion_script", '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, '#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("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("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("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("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("old_script", "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("user", "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("main_script", "new_script")) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/tests/test_parser.py0000644000175100001660000006156514737442253020624 0ustar00runnerdocker""" Unit tests for the SIEVE language parser. """ import unittest import os.path import codecs import io from sievelib.parser import Parser from sievelib.factory import FiltersSet import sievelib.commands class MytestCommand(sievelib.commands.ActionCommand): args_definition = [ { "name": "testtag", "type": ["tag"], "write_tag": True, "values": [":testtag"], "extra_arg": {"type": "number", "required": False}, "required": False, }, {"name": "recipients", "type": ["string", "stringlist"], "required": True}, ] class Quota_notificationCommand(sievelib.commands.ActionCommand): args_definition = [ { "name": "subject", "type": ["tag"], "write_tag": True, "values": [":subject"], "extra_arg": {"type": "string"}, "required": False, }, { "name": "recipient", "type": ["tag"], "write_tag": True, "values": [":recipient"], "extra_arg": {"type": "stringlist"}, "required": True, }, ] class SieveTest(unittest.TestCase): def setUp(self): self.parser = Parser() def __checkCompilation(self, script, result): self.assertEqual(self.parser.parse(script), result) def compilation_ok(self, script, **kwargs): self.__checkCompilation(script, True, **kwargs) def compilation_ko(self, script): self.__checkCompilation(script, False) def representation_is(self, content): target = io.StringIO() self.parser.dump(target) repr_ = target.getvalue() target.close() self.assertEqual(repr_, content.lstrip()) def sieve_is(self, content): filtersset = FiltersSet("Testfilterset") filtersset.from_parser_result(self.parser) target = io.StringIO() filtersset.tosieve(target) repr_ = target.getvalue() target.close() self.assertEqual(repr_, content) class AdditionalCommands(SieveTest): def test_add_command(self): self.assertRaises( sievelib.commands.UnknownCommand, sievelib.commands.get_command_instance, "mytest", ) sievelib.commands.add_commands(MytestCommand) sievelib.commands.get_command_instance("mytest") self.compilation_ok( b""" mytest :testtag 10 ["testrecp1@example.com"]; """ ) def test_quota_notification(self): sievelib.commands.add_commands(Quota_notificationCommand) quota_notification_sieve = """# Filter: Testrule\nquota_notification :subject "subject here" :recipient ["somerecipient@example.com"];\n""" self.compilation_ok(quota_notification_sieve) self.sieve_is(quota_notification_sieve) class ValidEncodings(SieveTest): def test_utf8_file(self): utf8_sieve = os.path.join(os.path.dirname(__file__), "files", "utf8_sieve.txt") with codecs.open(utf8_sieve, encoding="utf8") as fobj: source_sieve = fobj.read() self.parser.parse_file(utf8_sieve) self.sieve_is(source_sieve) class ValidSyntaxes(SieveTest): def test_hash_comment(self): self.compilation_ok( b""" if size :over 100k { # this is a comment discard; } """ ) self.representation_is( """ if (type: control) size (type: test) :over 100k discard (type: action) """ ) def test_bracket_comment(self): self.compilation_ok( b""" if size :over 100K { /* this is a comment this is still a comment */ discard /* this is a comment */ ; } """ ) self.representation_is( """ if (type: control) size (type: test) :over 100K discard (type: action) """ ) def test_string_with_bracket_comment(self): self.compilation_ok( b""" if header :contains "Cc" "/* comment */" { discard; } """ ) self.representation_is( """ if (type: control) header (type: test) :contains "Cc" "/* comment */" discard (type: action) """ ) def test_multiline_string(self): self.compilation_ok( b""" require "reject"; if allof (false, address :is ["From", "Sender"] ["blka@bla.com"]) { reject text: noreply ============================ Your email has been canceled ============================ . ; stop; } else { reject text: ================================ Your email has been canceled too ================================ . ; } """ ) self.representation_is( """ require (type: control) "reject" if (type: control) allof (type: test) false (type: test) address (type: test) :is ["From","Sender"] ["blka@bla.com"] reject (type: action) text: noreply ============================ Your email has been canceled ============================ . stop (type: action) else (type: control) reject (type: action) text: ================================ Your email has been canceled too ================================ . """ ) def test_complex_allof_with_not(self): """Test for allof/anyof commands including a not test. See https://github.com/tonioo/sievelib/issues/69. """ self.compilation_ok( b""" require ["fileinto", "reject"]; if allof (not allof (address :is ["From","sender"] ["test1@test2.priv","test2@test2.priv"], header :matches "Subject" "INACTIVE*"), address :is "From" "user3@test3.priv") { reject; } """ ) self.representation_is( """ require (type: control) ["fileinto","reject"] if (type: control) allof (type: test) not (type: test) allof (type: test) address (type: test) :is ["From","sender"] ["test1@test2.priv","test2@test2.priv"] header (type: test) :matches "Subject" "INACTIVE*" address (type: test) :is "From" "user3@test3.priv" reject (type: action) """ ) def test_nested_blocks(self): self.compilation_ok( b""" if header :contains "Sender" "example.com" { if header :contains "Sender" "me@" { discard; } elsif header :contains "Sender" "you@" { keep; } } """ ) self.representation_is( """ if (type: control) header (type: test) :contains "Sender" "example.com" if (type: control) header (type: test) :contains "Sender" "me@" discard (type: action) elsif (type: control) header (type: test) :contains "Sender" "you@" keep (type: action) """ ) def test_true_test(self): self.compilation_ok( b""" if true { } """ ) self.representation_is( """ if (type: control) true (type: test) """ ) def test_rfc5228_extended(self): self.compilation_ok( b""" # # Example Sieve Filter # Declare any optional features or extension used by the script # require ["fileinto"]; # # Handle messages from known mailing lists # Move messages from IETF filter discussion list to filter mailbox # if header :is "Sender" "owner-ietf-mta-filters@imc.org" { fileinto "filter"; # move to "filter" mailbox } # # Keep all messages to or from people in my company # elsif address :domain :is ["From", "To"] "example.com" { keep; # keep in "In" mailbox } # # Try and catch unsolicited email. If a message is not to me, # or it contains a subject known to be spam, file it away. # elsif anyof (NOT address :all :contains ["To", "Cc", "Bcc"] "me@example.com", header :matches "subject" ["*make*money*fast*", "*university*dipl*mas*"]) { fileinto "spam"; # move to "spam" mailbox } else { # Move all other (non-company) mail to "personal" # mailbox. fileinto "personal"; } """ ) self.representation_is( """ require (type: control) ["fileinto"] if (type: control) header (type: test) :is "Sender" "owner-ietf-mta-filters@imc.org" fileinto (type: action) "filter" elsif (type: control) address (type: test) :domain :is ["From","To"] "example.com" keep (type: action) elsif (type: control) anyof (type: test) not (type: test) address (type: test) :all :contains ["To","Cc","Bcc"] "me@example.com" header (type: test) :matches "subject" ["*make*money*fast*","*university*dipl*mas*"] fileinto (type: action) "spam" else (type: control) fileinto (type: action) "personal" """ ) def test_explicit_comparator(self): self.compilation_ok( b""" if header :contains :comparator "i;octet" "Subject" "MAKE MONEY FAST" { discard; } """ ) self.representation_is( """ if (type: control) header (type: test) :comparator "i;octet" :contains "Subject" "MAKE MONEY FAST" discard (type: action) """ ) def test_non_ordered_args(self): self.compilation_ok( b""" if address :all :is "from" "tim@example.com" { discard; } """ ) self.representation_is( """ if (type: control) address (type: test) :all :is "from" "tim@example.com" discard (type: action) """ ) def test_multiple_not(self): self.compilation_ok( b""" if not not not not true { stop; } """ ) self.representation_is( """ if (type: control) not (type: test) not (type: test) not (type: test) not (type: test) true (type: test) stop (type: action) """ ) def test_just_one_command(self): self.compilation_ok(b"keep;") self.representation_is( """ keep (type: action) """ ) def test_singletest_testlist(self): self.compilation_ok( b""" if anyof (true) { discard; } """ ) self.representation_is( """ if (type: control) anyof (type: test) true (type: test) discard (type: action) """ ) def test_multitest_testlist(self): self.compilation_ok( b""" if anyof(allof(address :contains "From" ""), allof(header :contains "Subject" "")) {} """ ) def test_truefalse_testlist(self): self.compilation_ok( b""" if anyof(true, false) { discard; } """ ) self.representation_is( """ if (type: control) anyof (type: test) true (type: test) false (type: test) discard (type: action) """ ) def test_vacationext_basic(self): self.compilation_ok( b""" require "vacation"; if header :contains "subject" "cyrus" { vacation "I'm out -- send mail to cyrus-bugs"; } else { vacation "I'm out -- call me at +1 304 555 0123"; } """ ) def test_vacationext_medium(self): self.compilation_ok( b""" require "vacation"; if header :contains "subject" "lunch" { vacation :handle "ran-away" "I'm out and can't meet for lunch"; } else { vacation :handle "ran-away" "I'm out"; } """ ) def test_vacationext_with_limit(self): self.compilation_ok( b""" require "vacation"; vacation :days 23 :addresses ["tjs@example.edu", "ts4z@landru.example.edu"] "I'm away until October 19. If it's an emergency, call 911, I guess." ; """ ) def test_vacationext_with_single_mail_address(self): self.compilation_ok( """ require "vacation"; vacation :days 23 :addresses "tjs@example.edu" "I'm away until October 19. If it's an emergency, call 911, I guess." ; """ ) def test_vacationext_with_multiline(self): self.compilation_ok( b""" require "vacation"; vacation :mime text: Content-Type: multipart/alternative; boundary=foo --foo I'm at the beach relaxing. Mmmm, surf... --foo Content-Type: text/html; charset=us-ascii How to relax

I'm at the beach relaxing. Mmmm, surf... --foo-- . ; """ ) def test_vacation_seconds(self): self.compilation_ok( """ require ["vacation", "vacation-seconds"]; vacation :seconds 10 :addresses ["test@example.org"] "Gone"; """ ) def test_reject_extension(self): self.compilation_ok( b""" require "reject"; if header :contains "subject" "viagra" { reject; } """ ) def test_fileinto_create(self): self.compilation_ok( b"""require ["fileinto", "mailbox"]; if header :is "Sender" "owner-ietf-mta-filters@imc.org" { fileinto :create "filter"; # move to "filter" mailbox } """ ) def test_imap4flags_extension(self): self.compilation_ok( rb""" require ["fileinto", "imap4flags", "variables"]; if size :over 1M { addflag "MyFlags" "Big"; if header :is "From" "boss@company.example.com" { # The message will be marked as "\Flagged Big" when filed into # mailbox "Big messages" addflag "MyFlags" "\\Flagged"; } fileinto :flags "${MyFlags}" "Big messages"; } """ ) def test_imap4flags_hasflag(self): self.compilation_ok( b""" require ["imap4flags", "fileinto"]; if hasflag ["test", "toto"] { fileinto "Test"; } addflag "Var1" "Truc"; if hasflag "Var1" "Truc" { fileinto "Truc"; } """ ) def test_body_extension(self): self.compilation_ok( b""" require ["body", "fileinto"]; if body :content "text" :contains ["missile", "coordinates"] { fileinto "secrets"; } """ ) self.compilation_ok( b""" require "body"; if body :raw :contains "MAKE MONEY FAST" { discard; } """ ) self.compilation_ok( b""" require ["body", "fileinto"]; # Save messages mentioning the project schedule in the # project/schedule folder. if body :text :contains "project schedule" { fileinto "project/schedule"; } """ ) class InvalidSyntaxes(SieveTest): def test_nested_comments(self): self.compilation_ko( b""" /* this is a comment /* with a nested comment inside */ it is allowed by the RFC :p */ """ ) def test_nonopened_block(self): self.compilation_ko( b""" if header :is "Sender" "me@example.com" discard; } """ ) def test_nonclosed_block(self): self.compilation_ko( b""" if header :is "Sender" "me@example.com" { discard; """ ) def test_nonopened_parenthesis(self): self.compilation_ko( b""" if header :is "Sender" "me@example.com") { discard; } """ ) def test_nonopened_block2(self): self.compilation_ko(b"""}""") def test_unknown_token(self): self.compilation_ko( b""" if header :is "Sender" "Toto" & header :contains "Cc" "Tata" { } """ ) def test_empty_string_list(self): self.compilation_ko(b"require [];") def test_unopened_string_list(self): self.compilation_ko(b'require "fileinto"];') def test_unclosed_string_list(self): self.compilation_ko(b'require ["toto", "tata";') def test_misplaced_comma_in_string_list(self): self.compilation_ko(b'require ["toto",];') def test_nonopened_tests_list(self): self.compilation_ko( b""" if anyof header :is "Sender" "me@example.com", header :is "Sender" "myself@example.com") { fileinto "trash"; } """ ) def test_nonclosed_tests_list(self): self.compilation_ko( b""" if anyof (header :is "Sender" "me@example.com", header :is "Sender" "myself@example.com" { fileinto "trash"; } """ ) def test_nonclosed_tests_list2(self): self.compilation_ko( b""" if anyof (header :is "Sender" { fileinto "trash"; } """ ) def test_misplaced_comma_in_tests_list(self): self.compilation_ko( b""" if anyof (header :is "Sender" "me@example.com",) { } """ ) def test_comma_inside_arguments(self): self.compilation_ko( b""" require "fileinto", "enveloppe"; """ ) def test_non_ordered_args(self): self.compilation_ko( b""" if address "From" :is "tim@example.com" { discard; } """ ) def test_extra_arg(self): self.compilation_ko( b""" if address :is "From" "tim@example.com" "tutu" { discard; } """ ) def test_empty_not(self): self.compilation_ko( b""" if not { discard; } """ ) def test_missing_semicolon(self): self.compilation_ko( b""" require ["fileinto"] """ ) def test_missing_semicolon_in_block(self): self.compilation_ko( b""" if true { stop } """ ) def test_misplaced_parenthesis(self): self.compilation_ko( b""" if (true) { } """ ) def test_control_command_in_test(self): self.compilation_ko( b""" if stop; """ ) def test_extra_test_in_simple_control(self): self.compilation_ko( b""" if address "From" "example.com" header "Subject" "Example" { stop; } """ ) def test_missing_comma_in_test_list(self): self.compilation_ko( b""" if allof(anyof(address "From" "example.com") header "Subject" "Example") { stop; } """ ) def test_vacation_seconds_no_arg(self): self.compilation_ko( """ require ["vacation", "vacation-seconds"]; vacation :seconds :addresses ["test@example.org"] "Gone"; """ ) class LanguageRestrictions(SieveTest): def test_unknown_control(self): self.compilation_ko( b""" macommande "Toto"; """ ) def test_misplaced_elsif(self): self.compilation_ko( b""" elsif true { } """ ) def test_misplaced_elsif2(self): self.compilation_ko( b""" elsif header :is "From" "toto" { } """ ) def test_misplaced_nested_elsif(self): self.compilation_ko( b""" if true { elsif false { } } """ ) def test_unexpected_argument(self): self.compilation_ko(b'stop "toto";') def test_bad_arg_value(self): self.compilation_ko( b""" if header :isnot "Sent" "me@example.com" { stop; } """ ) def test_bad_arg_value2(self): self.compilation_ko( b""" if header :isnot "Sent" 10000 { stop; } """ ) def test_bad_comparator_value(self): self.compilation_ko( b""" if header :contains :comparator "i;prout" "Subject" "MAKE MONEY FAST" { discard; } """ ) def test_not_included_extension(self): self.compilation_ko( b""" if header :contains "Subject" "MAKE MONEY FAST" { fileinto "spam"; } """ ) def test_test_outside_control(self): self.compilation_ko(b"true;") def test_fileinto_create_without_mailbox(self): self.compilation_ko( b"""require ["fileinto"]; if header :is "Sender" "owner-ietf-mta-filters@imc.org" { fileinto :create "filter"; # move to "filter" mailbox } """ ) self.assertEqual(self.parser.error, "line 4: extension 'mailbox' not loaded") def test_fileinto_create_without_fileinto(self): self.compilation_ko( b"""require ["mailbox"]; if header :is "Sender" "owner-ietf-mta-filters@imc.org" { fileinto :create "filter"; # move to "filter" mailbox } """ ) self.assertEqual(self.parser.error, "line 4: extension 'fileinto' not loaded") def test_unknown_command(self): self.compilation_ko( b"""require ["mailbox"]; if header :is "Sender" "owner-ietf-mta-filters@imc.org" { foobar :create "filter"; # move to "filter" mailbox } """ ) self.assertEqual(self.parser.error, "line 4: unknown command 'foobar'") def test_exists_get_string_or_list(self): self.compilation_ok( b""" if exists "subject" { discard; } """ ) self.compilation_ok( b""" if exists ["subject"] { discard; } """ ) class DateCommands(SieveTest): def test_date_command(self): self.compilation_ok( b"""require ["date", "relational", "fileinto"]; if allof(header :is "from" "boss@example.com", date :value "ge" :originalzone "date" "hour" "09", date :value "lt" :originalzone "date" "hour" "17") { fileinto "urgent"; } """ ) def test_currentdate_command(self): self.compilation_ok( b"""require ["date", "relational"]; if allof(currentdate :value "ge" "date" "2013-10-23", currentdate :value "le" "date" "2014-10-12") { discard; } """ ) def test_currentdate_command_timezone(self): self.compilation_ok( b"""require ["date", "relational"]; if allof(currentdate :zone "+0100" :value "ge" "date" "2013-10-23", currentdate :value "le" "date" "2014-10-12") { discard; } """ ) def test_currentdate_norel(self): self.compilation_ok( b"""require ["date"]; if allof ( currentdate :zone "+0100" :is "date" "2013-10-23" ) { discard; }""" ) def test_currentdate_extension_not_loaded(self): self.compilation_ko( b"""require ["date"]; if allof ( currentdate :value "ge" "date" "2013-10-23" , currentdate :value "le" "date" "2014-10-12" ) { discard; } """ ) class VariablesCommands(SieveTest): def test_set_command(self): self.compilation_ok( b"""require ["variables"]; set "matchsub" "testsubject"; if allof ( header :contains ["Subject"] "${header}" ) { discard; } """ ) class CopyWithoutSideEffectsTestCase(SieveTest): """RFC3894 test cases.""" def test_redirect_with_copy(self): self.compilation_ko( b""" if header :contains "subject" "test" { redirect :copy "dev@null.com"; } """ ) self.compilation_ok( b"""require "copy"; if header :contains "subject" "test" { redirect :copy "dev@null.com"; } """ ) def test_fileinto_with_copy(self): self.compilation_ko( b"""require "fileinto"; if header :contains "subject" "test" { fileinto :copy "Spam"; } """ ) self.assertEqual(self.parser.error, "line 3: extension 'copy' not loaded") self.compilation_ok( b"""require ["fileinto", "copy"]; if header :contains "subject" "test" { fileinto :copy "Spam"; } """ ) class RegexMatchTestCase(SieveTest): def test_header_regex(self): self.compilation_ok( b"""require "regex"; if header :regex "Subject" "^Test" { discard; } """ ) def test_header_regex_no_middle(self): self.compilation_ko( b"""require "regex"; if header "Subject" :regex "^Test" { discard; } """ ) def test_envelope_regex(self): self.compilation_ok( b"""require ["regex","envelope"]; if envelope :regex "from" "^test@example\\.org$" { discard; } """ ) def test_envelope_regex_no_middle(self): self.compilation_ko( b"""require "regex"; if envelope "from" :regex "^test@example\\.org$" { discard; } """ ) def test_address_regex(self): self.compilation_ok( b"""require "regex"; if address :regex "from" "^test@example\\.org$" { discard; } """ ) def test_address_regex_no_middle(self): self.compilation_ko( b"""require "regex"; if address "from" :regex "^test@example\\.org$" { discard; } """ ) def test_body_raw_regex(self): self.compilation_ok( b"""require ["body", "regex"]; if body :raw :regex "Sample" { discard; } """ ) def test_body_content_regex(self): self.compilation_ok( b"""require ["body", "regex"]; if body :content "text" :regex "Sample" { discard; } """ ) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328363.0 sievelib-1.4.2/sievelib/tools.py0000644000175100001660000000046514737442253016257 0ustar00runnerdocker"""Some tools.""" from typing import List def to_list(stringlist: str, unquote: bool = True) -> List[str]: """Convert a string representing a list to real list.""" stringlist = stringlist[1:-1] return [ string.strip('"') if unquote else string for string in stringlist.split(",") ] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1736328367.7439578 sievelib-1.4.2/sievelib.egg-info/0000755000175100001660000000000014737442260016230 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328367.0 sievelib-1.4.2/sievelib.egg-info/PKG-INFO0000644000175100001660000001215114737442257017333 0ustar00runnerdockerMetadata-Version: 2.1 Name: sievelib Version: 1.4.2 Summary: Client-side SIEVE library Home-page: https://github.com/tonioo/sievelib Author: Antoine Nguyen Author-email: tonio@ngyn.org License: MIT Keywords: sieve,managesieve,parser,client Classifier: Programming Language :: Python Classifier: Development Status :: 5 - Production/Stable 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 Requires-Python: >=3.7 License-File: COPYING sievelib ======== |workflow| |codecov| |latest-version| Client-side Sieve and Managesieve library written in Python. * Sieve : An Email Filtering Language (`RFC 5228 `_) * ManageSieve : A Protocol for Remotely Managing Sieve Scripts (`RFC 5804 `_) Installation ------------ To install ``sievelib`` from PyPI:: pip install sievelib To install sievelib from git:: git clone git@github.com:tonioo/sievelib.git cd sievelib python ./setup.py install Sieve tools ----------- What is supported ^^^^^^^^^^^^^^^^^ Currently, the provided parser supports most of the functionalities described in the RFC. The only exception concerns section *2.4.2.4. Encoding Characters Using "encoded-character"* which is not supported. The following extensions are also supported: * Copying Without Side Effects (`RFC 3894 `_) * Body (`RFC 5173 `_) * Vacation (`RFC 5230 `_) * Seconds parameter for Vacation (`RFC 6131 `_) * Relational (`RFC 5231 `_) * Imap4flags (`RFC 5232 `_) * Regular expression (`Draft `_) The following extensions are partially supported: * Date and Index (`RFC 5260 `_) * Checking Mailbox Status and Accessing Mailbox Metadata (`RFC 5490 `_) Extending the parser ^^^^^^^^^^^^^^^^^^^^ It is possible to extend the parser by adding new supported commands. For example:: import sievelib class MyCommand(sievelib.commands.ActionCommand): args_definition = [ {"name": "testtag", "type": ["tag"], "write_tag": True, "values": [":testtag"], "extra_arg": {"type": "number", "required": False}, "required": False}, {"name": "recipients", "type": ["string", "stringlist"], "required": True} ] sievelib.commands.add_commands(MyCommand) Basic usage ^^^^^^^^^^^ The parser can either be used from the command-line:: $ cd sievelib $ python parser.py test.sieve Syntax OK $ Or can be used from a python environment (or script/module):: >>> from sievelib.parser import Parser >>> p = Parser() >>> p.parse('require ["fileinto"];') True >>> p.dump() require (type: control) ["fileinto"] >>> >>> p.parse('require ["fileinto"]') False >>> p.error 'line 1: parsing error: end of script reached while semicolon expected' >>> Simple filters creation ^^^^^^^^^^^^^^^^^^^^^^^ Some high-level classes are provided with the ``factory`` module, they make the generation of Sieve rules easier:: >>> from sievelib.factory import FiltersSet >>> fs = FiltersSet("test") >>> fs.addfilter("rule1", ... [("Sender", ":is", "toto@toto.com"),], ... [("fileinto", "Toto"),]) >>> fs.tosieve() require ["fileinto"]; # Filter: rule1 if anyof (header :is "Sender" "toto@toto.com") { fileinto "Toto"; } >>> Additional documentation is available within source code. ManageSieve tools ----------------- What is supported ^^^^^^^^^^^^^^^^^ All mandatory commands are supported. The ``RENAME`` extension is supported, with a simulated behaviour for server that do not support it. For the ``AUTHENTICATE`` command, supported mechanisms are ``DIGEST-MD5``, ``PLAIN``, ``LOGIN`` and ``OAUTHBEARER``. 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 .. |workflow| image:: https://github.com/tonioo/sievelib/workflows/Sievelib/badge.svg .. |codecov| image:: https://codecov.io/github/tonioo/sievelib/graph/badge.svg?token=B1FWNSY60d :target: https://codecov.io/github/tonioo/sievelib ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328367.0 sievelib-1.4.2/sievelib.egg-info/SOURCES.txt0000644000175100001660000000115114737442257020120 0ustar00runnerdocker.gitignore .pre-commit-config.yaml COPYING MANIFEST.in README.rst dev-requirements.txt requirements.txt setup.cfg setup.py .github/workflows/sievelib.yml 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././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328367.0 sievelib-1.4.2/sievelib.egg-info/dependency_links.txt0000644000175100001660000000000114737442257022304 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328367.0 sievelib-1.4.2/sievelib.egg-info/requires.txt0000644000175100001660000000002214737442257020630 0ustar00runnerdockertyping-extensions ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1736328367.0 sievelib-1.4.2/sievelib.egg-info/top_level.txt0000644000175100001660000000001114737442257020760 0ustar00runnerdockersievelib