├── .github └── workflows │ └── sievelib.yml ├── .gitignore ├── .pre-commit-config.yaml ├── COPYING ├── MANIFEST.in ├── README.rst ├── dev-requirements.txt ├── pyproject.toml ├── requirements.txt └── sievelib ├── __init__.py ├── commands.py ├── digest_md5.py ├── factory.py ├── managesieve.py ├── parser.py ├── tests ├── __init__.py ├── files │ └── utf8_sieve.txt ├── test_factory.py ├── test_managesieve.py └── test_parser.py └── tools.py /.github/workflows/sievelib.yml: -------------------------------------------------------------------------------- 1 | name: Sievelib 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | release: 9 | branches: [ master ] 10 | types: [ published ] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [3.9, '3.10', '3.11', '3.12', '3.13'] 18 | fail-fast: false 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | pip install codecov pytest pytest-cov 28 | pip install -e . 29 | - name: Run tests 30 | if: ${{ matrix.python-version != '3.13' }} 31 | run: | 32 | pytest 33 | - name: Run tests and coverage 34 | if: ${{ matrix.python-version == '3.13' }} 35 | run: | 36 | pytest --cov=sievelib --cov-report xml 37 | - name: Upload coverage result 38 | if: ${{ matrix.python-version == '3.13' }} 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: coverage-results 42 | path: coverage.xml 43 | 44 | coverage: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Download coverage results 50 | uses: actions/download-artifact@v4 51 | with: 52 | name: coverage-results 53 | - name: Upload coverage to Codecov 54 | uses: codecov/codecov-action@v4 55 | with: 56 | token: ${{ secrets.CODECOV_TOKEN }} 57 | files: ./coverage.xml 58 | 59 | release: 60 | if: github.event_name != 'pull_request' 61 | needs: coverage 62 | runs-on: ubuntu-latest 63 | permissions: 64 | id-token: write 65 | environment: 66 | name: pypi 67 | url: https://pypi.org/p/sievelib 68 | steps: 69 | - uses: actions/checkout@v4 70 | with: 71 | fetch-depth: 0 72 | - name: Set up Python 3.13 73 | uses: actions/setup-python@v5 74 | with: 75 | python-version: '3.13' 76 | - name: Create package 77 | run: | 78 | python -m pip install build 79 | python -m build 80 | - name: Publish to Test PyPI 81 | if: endsWith(github.event.ref, '/master') 82 | uses: pypa/gh-action-pypi-publish@release/v1 83 | with: 84 | repository-url: https://test.pypi.org/legacy/ 85 | skip-existing: true 86 | - name: Publish distribution to PyPI 87 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 88 | uses: pypa/gh-action-pypi-publish@release/v1 89 | with: 90 | skip-existing: true 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*~$ 2 | *.pyc 3 | dist 4 | sievelib.egg-info 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: local 5 | hooks: 6 | - id: pylint 7 | name: pylint 8 | entry: pylint 9 | language: system 10 | types: [python] 11 | args: ["-rn", "-sn", "--fail-under=5"] 12 | - repo: https://github.com/psf/black 13 | rev: "24.2.0" 14 | hooks: 15 | - id: black 16 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2024 Antoine Nguyen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst COPYING requirements.txt 2 | include sievelib/tests/files/utf8_sieve.txt 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | sievelib 2 | ======== 3 | 4 | |workflow| |codecov| |latest-version| 5 | 6 | Client-side Sieve and Managesieve library written in Python. 7 | 8 | * Sieve : An Email Filtering Language 9 | (`RFC 5228 `_) 10 | * ManageSieve : A Protocol for Remotely Managing Sieve Scripts 11 | (`RFC 5804 `_) 12 | 13 | Installation 14 | ------------ 15 | 16 | To install ``sievelib`` from PyPI:: 17 | 18 | pip install sievelib 19 | 20 | To install sievelib from git:: 21 | 22 | git clone git@github.com:tonioo/sievelib.git 23 | cd sievelib 24 | python ./setup.py install 25 | 26 | Sieve tools 27 | ----------- 28 | 29 | What is supported 30 | ^^^^^^^^^^^^^^^^^ 31 | 32 | Currently, the provided parser supports most of the functionalities 33 | described in the RFC. The only exception concerns section 34 | *2.4.2.4. Encoding Characters Using "encoded-character"* which is not 35 | supported. 36 | 37 | The following extensions are also supported: 38 | 39 | * Copying Without Side Effects (`RFC 3894 `_) 40 | * Body (`RFC 5173 `_) 41 | * Vacation (`RFC 5230 `_) 42 | * Seconds parameter for Vacation (`RFC 6131 `_) 43 | * Relational (`RFC 5231 `_) 44 | * Imap4flags (`RFC 5232 `_) 45 | * Regular expression (`Draft `_) 46 | * Notifications (`RFC 5435 `_) 47 | 48 | The following extensions are partially supported: 49 | 50 | * Date and Index (`RFC 5260 `_) 51 | * Checking Mailbox Status and Accessing Mailbox Metadata (`RFC 5490 `_) 52 | 53 | Extending the parser 54 | ^^^^^^^^^^^^^^^^^^^^ 55 | 56 | It is possible to extend the parser by adding new supported 57 | commands. For example:: 58 | 59 | import sievelib 60 | 61 | class MyCommand(sievelib.commands.ActionCommand): 62 | args_definition = [ 63 | {"name": "testtag", 64 | "type": ["tag"], 65 | "write_tag": True, 66 | "values": [":testtag"], 67 | "extra_arg": {"type": "number", 68 | "required": False}, 69 | "required": False}, 70 | {"name": "recipients", 71 | "type": ["string", "stringlist"], 72 | "required": True} 73 | ] 74 | 75 | sievelib.commands.add_commands(MyCommand) 76 | 77 | Basic usage 78 | ^^^^^^^^^^^ 79 | 80 | The parser can either be used from the command-line:: 81 | 82 | $ cd sievelib 83 | $ python parser.py test.sieve 84 | Syntax OK 85 | $ 86 | 87 | Or can be used from a python environment (or script/module):: 88 | 89 | >>> from sievelib.parser import Parser 90 | >>> p = Parser() 91 | >>> p.parse('require ["fileinto"];') 92 | True 93 | >>> p.dump() 94 | require (type: control) 95 | ["fileinto"] 96 | >>> 97 | >>> p.parse('require ["fileinto"]') 98 | False 99 | >>> p.error 100 | 'line 1: parsing error: end of script reached while semicolon expected' 101 | >>> 102 | 103 | Simple filters creation 104 | ^^^^^^^^^^^^^^^^^^^^^^^ 105 | 106 | Some high-level classes are provided with the ``factory`` module, they 107 | make the generation of Sieve rules easier:: 108 | 109 | >>> from sievelib.factory import FiltersSet 110 | >>> fs = FiltersSet("test") 111 | >>> fs.addfilter("rule1", 112 | ... [("Sender", ":is", "toto@toto.com"),], 113 | ... [("fileinto", "Toto"),]) 114 | >>> fs.tosieve() 115 | require ["fileinto"]; 116 | 117 | # Filter: rule1 118 | if anyof (header :is "Sender" "toto@toto.com") { 119 | fileinto "Toto"; 120 | } 121 | >>> 122 | 123 | Additional documentation is available within source code. 124 | 125 | ManageSieve tools 126 | ----------------- 127 | 128 | What is supported 129 | ^^^^^^^^^^^^^^^^^ 130 | 131 | All mandatory commands are supported. The ``RENAME`` extension is 132 | supported, with a simulated behaviour for server that do not support 133 | it. 134 | 135 | For the ``AUTHENTICATE`` command, supported mechanisms are ``DIGEST-MD5``, 136 | ``PLAIN``, ``LOGIN`` and ``OAUTHBEARER``. 137 | 138 | Basic usage 139 | ^^^^^^^^^^^ 140 | 141 | The ManageSieve client is intended to be used from another python 142 | application (there isn't any shell provided):: 143 | 144 | >>> from sievelib.managesieve import Client 145 | >>> c = Client("server.example.com") 146 | >>> c.connect("user", "password", starttls=False, authmech="DIGEST-MD5") 147 | True 148 | >>> c.listscripts() 149 | ("active_script", ["script1", "script2"]) 150 | >>> c.setactive("script1") 151 | True 152 | >>> c.havespace("script3", 45) 153 | True 154 | >>> 155 | 156 | Additional documentation is available with source code. 157 | 158 | .. |latest-version| image:: https://badge.fury.io/py/sievelib.svg 159 | :target: https://badge.fury.io/py/sievelib 160 | .. |workflow| image:: https://github.com/tonioo/sievelib/workflows/Sievelib/badge.svg 161 | .. |codecov| image:: https://codecov.io/github/tonioo/sievelib/graph/badge.svg?token=B1FWNSY60d 162 | :target: https://codecov.io/github/tonioo/sievelib 163 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | black 3 | pylint 4 | pytest 5 | 6 | 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.4"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "sievelib" 7 | dynamic = [ 8 | "version", 9 | "dependencies", 10 | "optional-dependencies" 11 | ] 12 | authors = [ 13 | { name="Antoine Nguyen", email="tonio@ngyn.org" }, 14 | ] 15 | description = "Client-side SIEVE library" 16 | readme = "README.rst" 17 | requires-python = ">=3.9" 18 | classifiers = [ 19 | "Programming Language :: Python", 20 | "Development Status :: 5 - Production/Stable", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | "Topic :: Communications :: Email :: Filters", 26 | ] 27 | keywords = ["sieve", "managesieve", "parser", "client"] 28 | license = { file = "COPYING" } 29 | 30 | [project.urls] 31 | Repository = "https://github.com/tonioo/sievelib" 32 | Issues = "https://github.com/tonioo/sievelib/issues" 33 | 34 | [tool.setuptools.dynamic] 35 | version = { attr = "sievelib.get_version" } 36 | dependencies = { file = ["requirements.txt"] } 37 | optional-dependencies.dev = { file = ["dev-requirements.txt"] } 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | typing-extensions 2 | -------------------------------------------------------------------------------- /sievelib/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def local_scheme(version): 5 | return "" 6 | 7 | 8 | def get_version(): 9 | from setuptools_scm import get_version as default_version 10 | 11 | github_version = os.environ.get("GITHUB_REF_NAME", None) 12 | github_type = os.environ.get("GITHUB_REF_TYPE", None) 13 | if github_version is not None and github_type == "tag": 14 | print(f"GITHUB_REF_NAME found, using version: {github_version}") 15 | return github_version 16 | return default_version(local_scheme=local_scheme) 17 | -------------------------------------------------------------------------------- /sievelib/commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | SIEVE commands representation 3 | 4 | This module contains classes that represent known commands. They all 5 | inherit from the Command class which provides generic method for 6 | command manipulation or parsing. 7 | 8 | There are three command types (each one represented by a class): 9 | * control (ControlCommand) : Control structures are needed to allow 10 | for multiple and conditional actions 11 | * action (ActionCommand) : Actions that can be applied on emails 12 | * test (TestCommand) : Tests are used in conditionals to decide which 13 | part(s) of the conditional to execute 14 | 15 | Finally, each known command is represented by its own class which 16 | provides extra information such as: 17 | * expected arguments, 18 | * completion callback, 19 | * etc. 20 | 21 | """ 22 | 23 | from collections.abc import Iterable 24 | import sys 25 | from typing import Any, Dict, Iterator, List, Optional, TypedDict, Union 26 | from typing_extensions import NotRequired 27 | 28 | from . import tools 29 | 30 | 31 | class CommandError(Exception): 32 | """Base command exception class.""" 33 | 34 | 35 | class UnknownCommand(CommandError): 36 | """Specific exception raised when an unknown command is encountered""" 37 | 38 | def __init__(self, name: str): 39 | self.name = name 40 | 41 | def __str__(self): 42 | return "unknown command '%s'" % self.name 43 | 44 | 45 | class BadArgument(CommandError): 46 | """Specific exception raised when a bad argument is encountered""" 47 | 48 | def __init__(self, command, seen, expected): 49 | self.command = command 50 | self.seen = seen 51 | self.expected = expected 52 | 53 | def __str__(self): 54 | return "bad argument %s for command %s (%s expected)" % ( 55 | self.seen, 56 | self.command, 57 | self.expected, 58 | ) 59 | 60 | 61 | class BadValue(CommandError): 62 | """Specific exception raised when a bad argument value is encountered""" 63 | 64 | def __init__(self, argument, value): 65 | self.argument = argument 66 | self.value = value 67 | 68 | def __str__(self): 69 | return "bad value %s for argument %s" % (self.value, self.argument) 70 | 71 | 72 | class ExtensionNotLoaded(CommandError): 73 | """Raised when an extension is not loaded.""" 74 | 75 | def __init__(self, name): 76 | self.name = name 77 | 78 | def __str__(self): 79 | return "extension '{}' not loaded".format(self.name) 80 | 81 | 82 | class CommandExtraArg(TypedDict): 83 | """Type definition for command extra argument.""" 84 | 85 | type: Union[str, List[str]] 86 | values: NotRequired[List[str]] 87 | valid_for: NotRequired[List[str]] 88 | 89 | 90 | class CommandArg(TypedDict): 91 | """Type definition for command argument.""" 92 | 93 | name: str 94 | type: List[str] 95 | required: NotRequired[bool] 96 | values: NotRequired[List[str]] 97 | extra_arg: NotRequired[CommandExtraArg] 98 | extension: NotRequired[str] 99 | extension_values: NotRequired[Dict[str, str]] 100 | 101 | 102 | # Statement elements (see RFC, section 8.3) 103 | # They are used in different commands. 104 | comparator: CommandArg = { 105 | "name": "comparator", 106 | "type": ["tag"], 107 | "values": [":comparator"], 108 | "extra_arg": {"type": "string", "values": ['"i;octet"', '"i;ascii-casemap"']}, 109 | "required": False, 110 | } 111 | address_part: CommandArg = { 112 | "name": "address-part", 113 | "values": [":localpart", ":domain", ":all"], 114 | "type": ["tag"], 115 | "required": False, 116 | } 117 | match_type: CommandArg = { 118 | "name": "match-type", 119 | "values": [":is", ":contains", ":matches"], 120 | "extension_values": { 121 | ":count": "relational", 122 | ":value": "relational", 123 | ":regex": "regex", 124 | }, 125 | "extra_arg": { 126 | "type": "string", 127 | "values": ['"gt"', '"ge"', '"lt"', '"le"', '"eq"', '"ne"'], 128 | "valid_for": [":count", ":value"], 129 | }, 130 | "type": ["tag"], 131 | "required": False, 132 | } 133 | 134 | 135 | class Command: 136 | """Generic command representation. 137 | 138 | A command is described as follow: 139 | * A name 140 | * A type 141 | * A description of supported arguments 142 | * Does it accept an unknown quantity of arguments? (ex: anyof, allof) 143 | * Does it accept children? (ie. subcommands) 144 | * Is it an extension? 145 | * Must follow only certain commands 146 | 147 | """ 148 | 149 | args_definition: List[CommandArg] 150 | _type: str 151 | variable_args_nb: bool = False 152 | non_deterministic_args: bool = False 153 | accept_children: bool = False 154 | must_follow: Optional[List[str]] = None 155 | extension: Optional[str] = None 156 | 157 | def __init__(self, parent: Optional["Command"] = None): 158 | self.parent = parent 159 | self.arguments: Dict[str, Any] = {} 160 | self.extra_arguments: Dict[str, Any] = {} # to store tag arguments 161 | self.children: List[Command] = [] 162 | 163 | self.nextargpos = 0 164 | self.required_args = -1 165 | self.rargs_cnt = 0 166 | self.curarg: Union[CommandArg, None] = ( 167 | None # for arguments that expect an argument :p (ex: :comparator) 168 | ) 169 | 170 | self.name: str = self.__class__.__name__.replace("Command", "") 171 | self.name = self.name.lower() 172 | 173 | self.hash_comments: List[bytes] = [] 174 | 175 | def __repr__(self): 176 | return "%s (type: %s)" % (self.name, self._type) 177 | 178 | def tosieve(self, indentlevel: int = 0, target=sys.stdout): 179 | """Generate the sieve syntax corresponding to this command 180 | 181 | Recursive method. 182 | 183 | :param indentlevel: current indentation level 184 | :param target: opened file pointer where the content will be printed 185 | """ 186 | self.__print(self.name, indentlevel, nocr=True, target=target) 187 | if self.has_arguments(): 188 | for arg in self.args_definition: 189 | if not arg["name"] in self.arguments: 190 | continue 191 | target.write(" ") 192 | value = self.arguments[arg["name"]] 193 | atype = arg["type"] 194 | if "tag" in atype: 195 | target.write(value) 196 | if arg["name"] in self.extra_arguments: 197 | value = self.extra_arguments[arg["name"]] 198 | atype = arg["extra_arg"]["type"] 199 | target.write(" ") 200 | else: 201 | continue 202 | 203 | if type(value) == list: 204 | if self.__get_arg_type(arg["name"]) == ["testlist"]: 205 | target.write("(") 206 | for t in value: 207 | t.tosieve(target=target) 208 | if value.index(t) != len(value) - 1: 209 | target.write(", ") 210 | target.write(")") 211 | else: 212 | target.write( 213 | "[{}]".format( 214 | ", ".join(['"%s"' % v.strip('"') for v in value]) 215 | ) 216 | ) 217 | continue 218 | if isinstance(value, Command): 219 | value.tosieve(indentlevel, target=target) 220 | continue 221 | 222 | if "string" in atype: 223 | target.write(value) 224 | if not value.startswith('"') and not value.startswith("["): 225 | target.write("\n") 226 | else: 227 | target.write(str(value)) 228 | 229 | if not self.accept_children: 230 | if self.get_type() != "test": 231 | target.write(";\n") 232 | return 233 | if self.get_type() != "control": 234 | return 235 | target.write(" {\n") 236 | for ch in self.children: 237 | ch.tosieve(indentlevel + 4, target=target) 238 | self.__print("}", indentlevel, target=target) 239 | 240 | def __print( 241 | self, data: str, indentlevel: int, nocr: bool = False, target=sys.stdout 242 | ): 243 | text = "%s%s" % (" " * indentlevel, data) 244 | if nocr: 245 | target.write(text) 246 | else: 247 | target.write(text + "\n") 248 | 249 | def __get_arg_type(self, arg: str) -> Optional[List[str]]: 250 | """Return the type corresponding to the given name. 251 | 252 | :param arg: a defined argument name 253 | """ 254 | for a in self.args_definition: 255 | if a["name"] == arg: 256 | return a["type"] 257 | return None 258 | 259 | def complete_cb(self): 260 | """Completion callback 261 | 262 | Called when a command is considered as complete by the parser. 263 | """ 264 | pass 265 | 266 | def get_expected_first(self) -> Optional[List[str]]: 267 | """Return the first expected token for this command""" 268 | return None 269 | 270 | def has_arguments(self) -> bool: 271 | return len(self.args_definition) != 0 272 | 273 | def reassign_arguments(self): 274 | """Reassign arguments to proper slots. 275 | 276 | Should be called when parsing of commands with non 277 | deterministic arguments is considered done. 278 | """ 279 | raise NotImplementedError 280 | 281 | def dump(self, indentlevel: int = 0, target=sys.stdout): 282 | """Display the command 283 | 284 | Pretty printing of this command and its eventual arguments and 285 | children. (recursively) 286 | 287 | :param indentlevel: integer that indicates indentation level to apply 288 | """ 289 | self.__print(self, indentlevel, target=target) 290 | indentlevel += 4 291 | if self.has_arguments(): 292 | for arg in self.args_definition: 293 | if not arg["name"] in self.arguments: 294 | continue 295 | value = self.arguments[arg["name"]] 296 | atype = arg["type"] 297 | if "tag" in atype: 298 | self.__print(str(value), indentlevel, target=target) 299 | if arg["name"] in self.extra_arguments: 300 | value = self.extra_arguments[arg["name"]] 301 | atype = arg["extra_arg"]["type"] 302 | else: 303 | continue 304 | if type(value) == list: 305 | if self.__get_arg_type(arg["name"]) == ["testlist"]: 306 | for t in value: 307 | t.dump(indentlevel, target) 308 | else: 309 | self.__print( 310 | "[" + (",".join(value)) + "]", indentlevel, target=target 311 | ) 312 | continue 313 | if isinstance(value, Command): 314 | value.dump(indentlevel, target) 315 | continue 316 | self.__print(str(value), indentlevel, target=target) 317 | for ch in self.children: 318 | ch.dump(indentlevel, target) 319 | 320 | def walk(self) -> Iterator["Command"]: 321 | """Walk through commands.""" 322 | yield self 323 | if self.has_arguments(): 324 | for arg in self.args_definition: 325 | if not arg["name"] in self.arguments: 326 | continue 327 | value = self.arguments[arg["name"]] 328 | if type(value) == list: 329 | if self.__get_arg_type(arg["name"]) == ["testlist"]: 330 | for t in value: 331 | for node in t.walk(): 332 | yield node 333 | if isinstance(value, Command): 334 | for node in value.walk(): 335 | yield node 336 | for ch in self.children: 337 | for node in ch.walk(): 338 | yield node 339 | 340 | def addchild(self, child: "Command") -> bool: 341 | """Add a new child to the command 342 | 343 | A child corresponds to a command located into a block (this 344 | command's block). It can be either an action or a control. 345 | 346 | :param child: the new child 347 | :return: True on succes, False otherwise 348 | """ 349 | if not self.accept_children: 350 | return False 351 | self.children += [child] 352 | return True 353 | 354 | def iscomplete( 355 | self, atype: Optional[str] = None, avalue: Optional[str] = None 356 | ) -> bool: 357 | """Check if the command is complete 358 | 359 | Check if all required arguments have been encountered. For 360 | commands that allow an undefined number of arguments, this 361 | method always returns False. 362 | 363 | :return: True if command is complete, False otherwise 364 | """ 365 | if self.variable_args_nb: 366 | return False 367 | if self.required_args == -1: 368 | self.required_args = 0 369 | for arg in self.args_definition: 370 | if arg.get("required", False): 371 | self.required_args += 1 372 | return ( 373 | self.curarg is None 374 | or "extra_arg" not in self.curarg 375 | or ( 376 | "valid_for" in self.curarg["extra_arg"] 377 | and atype 378 | and atype in self.curarg["extra_arg"]["type"] 379 | and avalue not in self.curarg["extra_arg"]["valid_for"] 380 | ) 381 | ) and (self.rargs_cnt == self.required_args) 382 | 383 | def get_type(self) -> str: 384 | """Return the command's type""" 385 | if self._type is None: 386 | raise NotImplementedError 387 | return self._type 388 | 389 | def __is_valid_value_for_arg( 390 | self, arg: CommandArg, value: str, check_extension: bool = True 391 | ) -> bool: 392 | """Check if value is allowed for arg 393 | 394 | Some commands only allow a limited set of values. The method 395 | always returns True for methods that do not provide such a 396 | set. 397 | 398 | :param arg: the argument 399 | :param value: the value to check 400 | :param check_extension: check if value requires an extension 401 | :return: True on succes, False otherwise 402 | """ 403 | if "values" not in arg and "extension_values" not in arg: 404 | return True 405 | if "values" in arg and value.lower() in arg["values"]: 406 | return True 407 | if "extension_values" in arg: 408 | extension = arg["extension_values"].get(value.lower()) 409 | if extension: 410 | condition = ( 411 | check_extension 412 | and extension not in RequireCommand.loaded_extensions 413 | ) 414 | if condition: 415 | raise ExtensionNotLoaded(extension) 416 | return True 417 | return False 418 | 419 | def __is_valid_type(self, typ: str, typlist: List[str]) -> bool: 420 | """Check if type is valid based on input type list 421 | "string" is special because it can be used for stringlist 422 | 423 | :param typ: the type to check 424 | :param typlist: the list of type to check 425 | :return: True on success, False otherwise 426 | """ 427 | typ_is_str = typ == "string" 428 | str_list_in_typlist = "stringlist" in typlist 429 | 430 | return typ in typlist or (typ_is_str and str_list_in_typlist) 431 | 432 | def check_next_arg( 433 | self, atype: str, avalue: str, add: bool = True, check_extension: bool = True 434 | ) -> bool: 435 | """Argument validity checking 436 | 437 | This method is usually used by the parser to check if detected 438 | argument is allowed for this command. 439 | 440 | We make a distinction between required and optional 441 | arguments. Optional (or tagged) arguments can be provided 442 | unordered but not the required ones. 443 | 444 | A special handling is also done for arguments that require an 445 | argument (example: the :comparator argument expects a string 446 | argument). 447 | 448 | The "testlist" type is checked separately as we can't know in 449 | advance how many arguments will be provided. 450 | 451 | If the argument is incorrect, the method raises the 452 | appropriate exception, or return False to let the parser 453 | handle the exception. 454 | 455 | :param atype: the argument's type 456 | :param avalue: the argument's value 457 | :param add: indicates if this argument should be recorded on success 458 | :param check_extension: raise ExtensionNotLoaded if extension not 459 | loaded 460 | :return: True on success, False otherwise 461 | """ 462 | if not self.has_arguments(): 463 | return False 464 | if self.iscomplete(atype, avalue): 465 | return False 466 | 467 | 468 | if self.curarg is not None and "extra_arg" in self.curarg: 469 | condition = atype in self.curarg["extra_arg"]["type"] and ( 470 | "values" not in self.curarg["extra_arg"] 471 | or avalue in self.curarg["extra_arg"]["values"] 472 | ) 473 | if condition: 474 | if add: 475 | self.extra_arguments[self.curarg["name"]] = avalue 476 | self.curarg = None 477 | return True 478 | raise BadValue(self.curarg["name"], avalue) 479 | 480 | failed = False 481 | pos = self.nextargpos 482 | while pos < len(self.args_definition): 483 | curarg = self.args_definition[pos] 484 | if curarg.get("required", False): 485 | if curarg["type"] == ["testlist"]: 486 | if atype != "test": 487 | failed = True 488 | elif add: 489 | if not curarg["name"] in self.arguments: 490 | self.arguments[curarg["name"]] = [] 491 | self.arguments[curarg["name"]] += [avalue] 492 | elif not self.__is_valid_type( 493 | atype, curarg["type"] 494 | ) or not self.__is_valid_value_for_arg(curarg, avalue, check_extension): 495 | failed = True 496 | else: 497 | self.curarg = curarg 498 | self.rargs_cnt += 1 499 | self.nextargpos = pos + 1 500 | if add: 501 | self.arguments[curarg["name"]] = avalue 502 | break 503 | 504 | condition: bool = atype in curarg["type"] and self.__is_valid_value_for_arg( 505 | curarg, avalue, check_extension 506 | ) 507 | if condition: 508 | ext = curarg.get("extension") 509 | condition = ( 510 | check_extension 511 | and ext 512 | and ext not in RequireCommand.loaded_extensions 513 | ) 514 | if condition: 515 | raise ExtensionNotLoaded(ext) 516 | condition = "extra_arg" in curarg and ( 517 | "valid_for" not in curarg["extra_arg"] 518 | or avalue in curarg["extra_arg"]["valid_for"] 519 | ) 520 | if condition: 521 | self.curarg = curarg 522 | if add: 523 | self.arguments[curarg["name"]] = avalue 524 | break 525 | 526 | pos += 1 527 | 528 | if failed: 529 | raise BadArgument(self.name, avalue, self.args_definition[pos]["type"]) 530 | return True 531 | 532 | def __contains__(self, name: str) -> bool: 533 | """Check if argument is provided with command.""" 534 | return name in self.arguments 535 | 536 | def __getitem__(self, name: str) -> Any: 537 | """Shorcut to access a command argument 538 | 539 | :param name: the argument's name 540 | """ 541 | found = False 542 | for ad in self.args_definition: 543 | if ad["name"] == name: 544 | found = True 545 | break 546 | if not found: 547 | raise KeyError(name) 548 | if name not in self.arguments: 549 | raise KeyError(name) 550 | return self.arguments[name] 551 | 552 | 553 | class ControlCommand(Command): 554 | """Indermediate class to represent "control" commands""" 555 | 556 | _type = "control" 557 | 558 | 559 | class RequireCommand(ControlCommand): 560 | """The 'require' command 561 | 562 | This class has one big difference with others as it is used to 563 | store loaded extension names. (The result is we can check for 564 | unloaded extensions during the parsing) 565 | """ 566 | 567 | args_definition = [ 568 | {"name": "capabilities", "type": ["string", "stringlist"], "required": True} 569 | ] 570 | 571 | loaded_extensions: List[str] = [] 572 | 573 | def complete_cb(self): 574 | if type(self.arguments["capabilities"]) != list: 575 | exts = [self.arguments["capabilities"]] 576 | else: 577 | exts = self.arguments["capabilities"] 578 | for ext in exts: 579 | ext = ext.strip('"') 580 | if ext not in RequireCommand.loaded_extensions: 581 | RequireCommand.loaded_extensions += [ext] 582 | 583 | 584 | class IfCommand(ControlCommand): 585 | accept_children = True 586 | 587 | args_definition = [{"name": "test", "type": ["test"], "required": True}] 588 | 589 | def get_expected_first(self) -> List[str]: 590 | return ["identifier"] 591 | 592 | 593 | class ElsifCommand(ControlCommand): 594 | accept_children = True 595 | must_follow = ["if", "elsif"] 596 | args_definition = [{"name": "test", "type": ["test"], "required": True}] 597 | 598 | def get_expected_first(self) -> List[str]: 599 | return ["identifier"] 600 | 601 | 602 | class ElseCommand(ControlCommand): 603 | accept_children = True 604 | must_follow = ["if", "elsif"] 605 | args_definition = [] 606 | 607 | 608 | class ActionCommand(Command): 609 | """Indermediate class to represent "action" commands""" 610 | 611 | _type = "action" 612 | 613 | def args_as_tuple(self): 614 | args = [] 615 | for name, value in list(self.arguments.items()): 616 | unquote = False 617 | for argdef in self.args_definition: 618 | if name == argdef["name"]: 619 | condition = ( 620 | "string" in argdef["type"] or "stringlist" in argdef["type"] 621 | ) 622 | if condition: 623 | unquote = True 624 | break 625 | if unquote: 626 | if "," in value: 627 | args += tools.to_list(value) 628 | else: 629 | args.append(value.strip('"')) 630 | continue 631 | args.append(value) 632 | return (self.name,) + tuple(args) 633 | 634 | 635 | class StopCommand(ActionCommand): 636 | args_definition = [] 637 | 638 | 639 | class FileintoCommand(ActionCommand): 640 | extension = "fileinto" 641 | args_definition = [ 642 | { 643 | "name": "copy", 644 | "type": ["tag"], 645 | "values": [":copy"], 646 | "required": False, 647 | "extension": "copy", 648 | }, 649 | { 650 | "name": "create", 651 | "type": ["tag"], 652 | "values": [":create"], 653 | "required": False, 654 | "extension": "mailbox", 655 | }, 656 | { 657 | "name": "flags", 658 | "type": ["tag"], 659 | "values": [":flags"], 660 | "extra_arg": {"type": ["string", "stringlist"]}, 661 | "extension": "imap4flags", 662 | }, 663 | {"name": "mailbox", "type": ["string"], "required": True}, 664 | ] 665 | 666 | 667 | class RedirectCommand(ActionCommand): 668 | args_definition = [ 669 | { 670 | "name": "copy", 671 | "type": ["tag"], 672 | "values": [":copy"], 673 | "required": False, 674 | "extension": "copy", 675 | }, 676 | {"name": "address", "type": ["string"], "required": True}, 677 | ] 678 | 679 | 680 | class RejectCommand(ActionCommand): 681 | extension = "reject" 682 | args_definition = [{"name": "text", "type": ["string"], "required": True}] 683 | 684 | 685 | class KeepCommand(ActionCommand): 686 | args_definition = [ 687 | { 688 | "name": "flags", 689 | "type": ["tag"], 690 | "values": [":flags"], 691 | "extra_arg": {"type": ["string", "stringlist"]}, 692 | "extension": "imap4flags", 693 | }, 694 | ] 695 | 696 | 697 | class DiscardCommand(ActionCommand): 698 | args_definition = [] 699 | 700 | 701 | class SetflagCommand(ActionCommand): 702 | """imap4flags extension: setflag.""" 703 | 704 | args_definition = [ 705 | {"name": "variable-name", "type": ["string"], "required": False}, 706 | {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True}, 707 | ] 708 | extension = "imap4flags" 709 | 710 | 711 | class AddflagCommand(ActionCommand): 712 | """imap4flags extension: addflag.""" 713 | 714 | args_definition = [ 715 | {"name": "variable-name", "type": ["string"], "required": False}, 716 | {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True}, 717 | ] 718 | extension = "imap4flags" 719 | 720 | 721 | class RemoveflagCommand(ActionCommand): 722 | """imap4flags extension: removeflag.""" 723 | 724 | args_definition = [ 725 | {"name": "variable-name", "type": ["string"]}, 726 | {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True}, 727 | ] 728 | extension = "imap4flags" 729 | 730 | class NotifyCommand(ActionCommand): 731 | """ 732 | Notify extension 733 | 734 | https://datatracker.ietf.org/doc/html/rfc5435 735 | """ 736 | extension = "enotify" 737 | args_definition = [ 738 | { 739 | "name": "from", 740 | "type": ["tag"], 741 | "values": [":from"], 742 | "required": False, 743 | "extra_arg": {"type": "string", "required": True} 744 | }, 745 | { 746 | "name": "importance", 747 | "type": ["tag"], 748 | "values": [":importance"], 749 | "required": False, 750 | "extra_arg": {"type": "string", "required": True} 751 | }, 752 | { 753 | "name": "options", 754 | "type": ["tag"], 755 | "values": [":options"], 756 | "required": False, 757 | "extra_arg": {"type": "stringlist", "required": True} 758 | }, 759 | { 760 | "name": "message", 761 | "type": ["tag"], 762 | "values": [":message"], 763 | "required": False, 764 | "extra_arg": {"type": "string", "required": True} 765 | }, 766 | {"name": "method", "type": ["string"], "required": True}, 767 | ] 768 | 769 | class TestCommand(Command): 770 | """Indermediate class to represent "test" commands""" 771 | 772 | _type = "test" 773 | 774 | 775 | class AddressCommand(TestCommand): 776 | args_definition = [ 777 | comparator, 778 | address_part, 779 | match_type, 780 | {"name": "header-list", "type": ["string", "stringlist"], "required": True}, 781 | {"name": "key-list", "type": ["string", "stringlist"], "required": True}, 782 | ] 783 | 784 | 785 | class AllofCommand(TestCommand): 786 | accept_children = True 787 | variable_args_nb = True 788 | 789 | args_definition = [{"name": "tests", "type": ["testlist"], "required": True}] 790 | 791 | def get_expected_first(self) -> List[str]: 792 | return ["left_parenthesis"] 793 | 794 | 795 | class AnyofCommand(TestCommand): 796 | accept_children = True 797 | variable_args_nb = True 798 | 799 | args_definition = [{"name": "tests", "type": ["testlist"], "required": True}] 800 | 801 | def get_expected_first(self) -> List[str]: 802 | return ["left_parenthesis"] 803 | 804 | 805 | class EnvelopeCommand(TestCommand): 806 | args_definition = [ 807 | comparator, 808 | address_part, 809 | match_type, 810 | {"name": "header-list", "type": ["string", "stringlist"], "required": True}, 811 | {"name": "key-list", "type": ["string", "stringlist"], "required": True}, 812 | ] 813 | extension = "envelope" 814 | 815 | def args_as_tuple(self): 816 | """Return arguments as a list.""" 817 | result = ("envelope", self.arguments["match-type"]) 818 | value = self.arguments["header-list"] 819 | if isinstance(value, list): 820 | # FIXME 821 | value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) 822 | if value.startswith("["): 823 | result += (tools.to_list(value),) 824 | else: 825 | result += ([value.strip('"')],) 826 | value = self.arguments["key-list"] 827 | if isinstance(value, list): 828 | # FIXME 829 | value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) 830 | if value.startswith("["): 831 | result += (tools.to_list(value),) 832 | else: 833 | result = result + ([value.strip('"')],) 834 | return result 835 | 836 | 837 | class ExistsCommand(TestCommand): 838 | args_definition = [ 839 | {"name": "header-names", "type": ["string", "stringlist"], "required": True} 840 | ] 841 | 842 | def args_as_tuple(self): 843 | """FIXME: en fonction de la manière dont la commande a été générée 844 | (factory ou parser), le type des arguments est différent : 845 | string quand ça vient de la factory ou type normal depuis le 846 | parser. Il faut uniformiser tout ça !! 847 | 848 | """ 849 | value = self.arguments["header-names"] 850 | if isinstance(value, list): 851 | value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) 852 | if not value.startswith("["): 853 | return ("exists", value.strip('"')) 854 | return ("exists",) + tuple(tools.to_list(value)) 855 | 856 | 857 | class TrueCommand(TestCommand): 858 | args_definition = [] 859 | 860 | 861 | class FalseCommand(TestCommand): 862 | args_definition = [] 863 | 864 | 865 | class HeaderCommand(TestCommand): 866 | args_definition = [ 867 | comparator, 868 | match_type, 869 | {"name": "header-names", "type": ["string", "stringlist"], "required": True}, 870 | {"name": "key-list", "type": ["string", "stringlist"], "required": True}, 871 | ] 872 | 873 | def args_as_tuple(self): 874 | """Return arguments as a list.""" 875 | if "," in self.arguments["header-names"]: 876 | result = tuple(tools.to_list(self.arguments["header-names"])) 877 | else: 878 | result = (self.arguments["header-names"].strip('"'),) 879 | result = result + (self.arguments["match-type"],) 880 | if "," in self.arguments["key-list"]: 881 | result = result + tuple( 882 | tools.to_list(self.arguments["key-list"], unquote=False) 883 | ) 884 | else: 885 | result = result + (self.arguments["key-list"].strip('"'),) 886 | return result 887 | 888 | 889 | class BodyCommand(TestCommand): 890 | """Body extension. 891 | 892 | See https://tools.ietf.org/html/rfc5173. 893 | """ 894 | 895 | args_definition = [ 896 | comparator, 897 | match_type, 898 | { 899 | "name": "body-transform", 900 | "values": [":raw", ":content", ":text"], 901 | "extra_arg": {"type": "stringlist", "valid_for": [":content"]}, 902 | "type": ["tag"], 903 | "required": False, 904 | }, 905 | {"name": "key-list", "type": ["string", "stringlist"], "required": True}, 906 | ] 907 | extension = "body" 908 | 909 | def args_as_tuple(self): 910 | """Return arguments as a list.""" 911 | result = ("body",) 912 | result = result + ( 913 | self.arguments["body-transform"], 914 | self.arguments["match-type"], 915 | ) 916 | value = self.arguments["key-list"] 917 | if isinstance(value, list): 918 | # FIXME 919 | value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) 920 | if value.startswith("["): 921 | result += tuple(tools.to_list(value)) 922 | else: 923 | result += (value.strip('"'),) 924 | return result 925 | 926 | 927 | class NotCommand(TestCommand): 928 | accept_children = True 929 | 930 | args_definition = [{"name": "test", "type": ["test"], "required": True}] 931 | 932 | def get_expected_first(self): 933 | return ["identifier"] 934 | 935 | 936 | class SizeCommand(TestCommand): 937 | args_definition = [ 938 | { 939 | "name": "comparator", 940 | "type": ["tag"], 941 | "values": [":over", ":under"], 942 | "required": True, 943 | }, 944 | {"name": "limit", "type": ["number"], "required": True}, 945 | ] 946 | 947 | def args_as_tuple(self): 948 | return ("size", self.arguments["comparator"], self.arguments["limit"]) 949 | 950 | 951 | class HasflagCommand(TestCommand): 952 | """imap4flags extension: hasflag.""" 953 | 954 | args_definition = [ 955 | comparator, 956 | match_type, 957 | {"name": "variable-list", "type": ["string", "stringlist"], "required": False}, 958 | {"name": "list-of-flags", "type": ["string", "stringlist"], "required": True}, 959 | ] 960 | extension = "imap4flags" 961 | non_deterministic_args = True 962 | 963 | def reassign_arguments(self): 964 | """Deal with optional stringlist before a required one.""" 965 | condition = ( 966 | "variable-list" in self.arguments and "list-of-flags" not in self.arguments 967 | ) 968 | if condition: 969 | self.arguments["list-of-flags"] = self.arguments.pop("variable-list") 970 | self.rargs_cnt = 1 971 | 972 | 973 | class DateCommand(TestCommand): 974 | """date command, part of the date extension. 975 | 976 | https://tools.ietf.org/html/rfc5260#section-4 977 | """ 978 | 979 | extension = "date" 980 | args_definition = [ 981 | { 982 | "name": "zone", 983 | "type": ["tag"], 984 | "values": [":zone", ":originalzone"], 985 | "extra_arg": {"type": "string", "valid_for": [":zone"]}, 986 | "required": False, 987 | }, 988 | comparator, 989 | match_type, 990 | {"name": "header-name", "type": ["string"], "required": True}, 991 | {"name": "date-part", "type": ["string"], "required": True}, 992 | {"name": "key-list", "type": ["string", "stringlist"], "required": True}, 993 | ] 994 | 995 | 996 | class CurrentdateCommand(TestCommand): 997 | """currentdate command, part of the date extension. 998 | 999 | http://tools.ietf.org/html/rfc5260#section-5 1000 | """ 1001 | 1002 | extension = "date" 1003 | args_definition = [ 1004 | { 1005 | "name": "zone", 1006 | "type": ["tag"], 1007 | "values": [":zone"], 1008 | "extra_arg": {"type": "string"}, 1009 | "required": False, 1010 | }, 1011 | comparator, 1012 | match_type, 1013 | {"name": "date-part", "type": ["string"], "required": True}, 1014 | {"name": "key-list", "type": ["string", "stringlist"], "required": True}, 1015 | ] 1016 | 1017 | def args_as_tuple(self): 1018 | """Return arguments as a list.""" 1019 | result = ("currentdate",) 1020 | result += ( 1021 | ":zone", 1022 | self.extra_arguments["zone"].strip('"'), 1023 | self.arguments["match-type"], 1024 | ) 1025 | if self.arguments["match-type"] in [":count", ":value"]: 1026 | result += (self.extra_arguments["match-type"].strip('"'),) 1027 | result += (self.arguments["date-part"].strip('"'),) 1028 | value = self.arguments["key-list"] 1029 | if isinstance(value, list): 1030 | # FIXME 1031 | value = "[{}]".format(",".join('"{}"'.format(item) for item in value)) 1032 | if value.startswith("["): 1033 | result = result + tuple(tools.to_list(value)) 1034 | else: 1035 | result = result + (value.strip('"'),) 1036 | return result 1037 | 1038 | 1039 | class VacationCommand(ActionCommand): 1040 | extension = "vacation" 1041 | args_definition = [ 1042 | { 1043 | "name": "subject", 1044 | "type": ["tag"], 1045 | "values": [":subject"], 1046 | "extra_arg": {"type": "string"}, 1047 | "required": False, 1048 | }, 1049 | { 1050 | "name": "days", 1051 | "type": ["tag"], 1052 | "values": [":days"], 1053 | "extra_arg": {"type": "number"}, 1054 | "required": False, 1055 | }, 1056 | { 1057 | "name": "seconds", 1058 | "type": ["tag"], 1059 | "extension_values": {":seconds": "vacation-seconds"}, 1060 | "extra_arg": {"type": "number"}, 1061 | "required": False, 1062 | }, 1063 | { 1064 | "name": "from", 1065 | "type": ["tag"], 1066 | "values": [":from"], 1067 | "extra_arg": {"type": "string"}, 1068 | "required": False, 1069 | }, 1070 | { 1071 | "name": "addresses", 1072 | "type": ["tag"], 1073 | "values": [":addresses"], 1074 | "extra_arg": {"type": ["string", "stringlist"]}, 1075 | "required": False, 1076 | }, 1077 | { 1078 | "name": "handle", 1079 | "type": ["tag"], 1080 | "values": [":handle"], 1081 | "extra_arg": {"type": "string"}, 1082 | "required": False, 1083 | }, 1084 | {"name": "mime", "type": ["tag"], "values": [":mime"], "required": False}, 1085 | {"name": "reason", "type": ["string"], "required": True}, 1086 | ] 1087 | 1088 | 1089 | class SetCommand(ControlCommand): 1090 | """set command, part of the variables extension 1091 | 1092 | http://tools.ietf.org/html/rfc5229 1093 | """ 1094 | 1095 | extension = "variables" 1096 | args_definition = [ 1097 | {"name": "startend", "type": ["string"], "required": True}, 1098 | {"name": "date", "type": ["string"], "required": True}, 1099 | ] 1100 | 1101 | 1102 | def add_commands(cmds): 1103 | """ 1104 | Adds one or more commands to the module namespace. 1105 | Commands must end in "Command" to be added. 1106 | Example (see tests/parser.py): 1107 | sievelib.commands.add_commands(MytestCommand) 1108 | 1109 | :param cmds: a single Command Object or list of Command Objects 1110 | """ 1111 | if not isinstance(cmds, Iterable): 1112 | cmds = [cmds] 1113 | 1114 | for command in cmds: 1115 | if command.__name__.endswith("Command"): 1116 | globals()[command.__name__] = command 1117 | 1118 | 1119 | def get_command_instance( 1120 | name: str, parent: Optional[Command] = None, checkexists: bool = True 1121 | ) -> Command: 1122 | """Try to guess and create the appropriate command instance 1123 | 1124 | Given a command name (encountered by the parser), construct the 1125 | associated class name and, if known, return a new instance. 1126 | 1127 | If the command is not known or has not been loaded using require, 1128 | an UnknownCommand exception is raised. 1129 | 1130 | :param name: the command's name 1131 | :param parent: the eventual parent command 1132 | :return: a new class instance 1133 | """ 1134 | cname = "%sCommand" % name.lower().capitalize() 1135 | gl = globals() 1136 | condition = cname not in gl 1137 | if condition: 1138 | raise UnknownCommand(name) 1139 | condition = ( 1140 | checkexists 1141 | and gl[cname].extension 1142 | and gl[cname].extension not in RequireCommand.loaded_extensions 1143 | ) 1144 | if condition: 1145 | raise ExtensionNotLoaded(gl[cname].extension) 1146 | return gl[cname](parent) 1147 | -------------------------------------------------------------------------------- /sievelib/digest_md5.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple Digest-MD5 implementation (client side) 3 | 4 | Implementation based on RFC 2831 (http://www.ietf.org/rfc/rfc2831.txt) 5 | """ 6 | 7 | import base64 8 | import hashlib 9 | import binascii 10 | import re 11 | import random 12 | 13 | 14 | class DigestMD5(object): 15 | def __init__(self, challenge, digesturi): 16 | self.__digesturi = digesturi 17 | self.__challenge = challenge 18 | 19 | self.__params = {} 20 | pexpr = re.compile(r'(\w+)="(.+)"') 21 | for elt in base64.b64decode(challenge).split(","): 22 | m = pexpr.match(elt) 23 | if m is None: 24 | continue 25 | self.__params[m.group(1)] = m.group(2) 26 | 27 | def __make_cnonce(self): 28 | ret = "" 29 | for i in xrange(12): 30 | ret += chr(random.randint(0, 0xFF)) 31 | return base64.b64encode(ret) 32 | 33 | def __digest(self, value): 34 | return hashlib.md5(value).digest() 35 | 36 | def __hexdigest(self, value): 37 | return binascii.hexlify(hashlib.md5(value).digest()) 38 | 39 | def __make_response(self, username, password, check=False): 40 | a1 = "%s:%s:%s" % ( 41 | self.__digest("%s:%s:%s" % (username, self.realm, password)), 42 | self.__params["nonce"], 43 | self.cnonce, 44 | ) 45 | if check: 46 | a2 = ":%s" % self.__digesturi 47 | else: 48 | a2 = "AUTHENTICATE:%s" % self.__digesturi 49 | resp = "%s:%s:00000001:%s:auth:%s" % ( 50 | self.__hexdigest(a1), 51 | self.__params["nonce"], 52 | self.cnonce, 53 | self.__hexdigest(a2), 54 | ) 55 | 56 | return self.__hexdigest(resp) 57 | 58 | def response(self, username, password, authz_id=""): 59 | self.realm = self.__params["realm"] if self.__params.has_key("realm") else "" 60 | self.cnonce = self.__make_cnonce() 61 | respvalue = self.__make_response(username, password) 62 | 63 | dgres = ( 64 | 'username="%s",%snonce="%s",cnonce="%s",nc=00000001,qop=auth,' 65 | 'digest-uri="%s",response=%s' 66 | % ( 67 | username, 68 | ('realm="%s",' % self.realm) if len(self.realm) else "", 69 | self.__params["nonce"], 70 | self.cnonce, 71 | self.__digesturi, 72 | respvalue, 73 | ) 74 | ) 75 | if authz_id: 76 | if type(authz_id) is unicode: 77 | authz_id = authz_id.encode("utf-8") 78 | dgres += ',authzid="%s"' % authz_id 79 | 80 | return base64.b64encode(dgres) 81 | 82 | def check_last_challenge(self, username, password, value): 83 | challenge = base64.b64decode(value.strip('"')) 84 | return challenge == ( 85 | "rspauth=%s" % self.__make_response(username, password, True) 86 | ) 87 | -------------------------------------------------------------------------------- /sievelib/factory.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tools for simpler sieve filters generation. 3 | 4 | This module is intented to facilitate the creation of sieve filters 5 | without having to write or to know the syntax. 6 | 7 | Only commands (control/test/action) defined in the ``commands`` module 8 | are supported. 9 | """ 10 | 11 | import io 12 | import sys 13 | from typing import List, Optional, TypedDict, Union 14 | from typing_extensions import NotRequired 15 | 16 | from sievelib import commands 17 | from sievelib.parser import Parser 18 | 19 | 20 | class FilterAlreadyExists(Exception): 21 | pass 22 | 23 | 24 | class Filter(TypedDict): 25 | """Type definition for filter.""" 26 | 27 | name: str 28 | content: commands.Command 29 | enabled: bool 30 | description: NotRequired[str] 31 | 32 | 33 | class FiltersSet: 34 | """A set of filters.""" 35 | 36 | def __init__( 37 | self, 38 | name: str, 39 | filter_name_pretext: str = "# Filter: ", 40 | filter_desc_pretext: str = "# Description: ", 41 | ): 42 | """ 43 | Represents a set of one or more filters. 44 | 45 | :param name: the filterset's name 46 | :param filter_name_pretext: the text that is used to mark a filter name 47 | (as comment preceding the filter) 48 | :param filter_desc_pretext: the text that is used to mark a filter 49 | description 50 | """ 51 | self.name = name 52 | self.filter_name_pretext = filter_name_pretext 53 | self.filter_desc_pretext = filter_desc_pretext 54 | self.requires: List[str] = [] 55 | self.filters: List[Filter] = [] 56 | 57 | def __str__(self): 58 | target = io.StringIO() 59 | self.tosieve(target) 60 | ret = target.getvalue() 61 | target.close() 62 | return ret 63 | 64 | def __isdisabled(self, fcontent: commands.Command) -> bool: 65 | """Tells if a filter is disabled or not 66 | 67 | Simply checks if the filter is surrounded by a "if false" test. 68 | 69 | :param fcontent: the filter's name 70 | """ 71 | if not isinstance(fcontent, commands.IfCommand): 72 | return False 73 | if not isinstance(fcontent["test"], commands.FalseCommand): 74 | return False 75 | return True 76 | 77 | def from_parser_result(self, parser: Parser) -> None: 78 | cpt = 1 79 | for f in parser.result: 80 | if isinstance(f, commands.RequireCommand): 81 | if type(f.arguments["capabilities"]) == list: 82 | [self.require(c) for c in f.arguments["capabilities"]] 83 | else: 84 | self.require(f.arguments["capabilities"]) 85 | continue 86 | 87 | name = "Unnamed rule %d" % cpt 88 | description = "" 89 | for comment in f.hash_comments: 90 | if isinstance(comment, bytes): 91 | comment = comment.decode("utf-8") 92 | if comment.startswith(self.filter_name_pretext): 93 | name = comment.replace(self.filter_name_pretext, "") 94 | if comment.startswith(self.filter_desc_pretext): 95 | description = comment.replace(self.filter_desc_pretext, "") 96 | self.filters += [ 97 | { 98 | "name": name, 99 | "description": description, 100 | "content": f, 101 | "enabled": not self.__isdisabled(f), 102 | } 103 | ] 104 | cpt += 1 105 | 106 | def require(self, name: str): 107 | """Add a new extension to the requirements list 108 | 109 | :param name: the extension's name 110 | """ 111 | name = name.strip('"') 112 | if name not in self.requires: 113 | self.requires += [name] 114 | 115 | def check_if_arg_is_extension(self, arg: str): 116 | """Include extension if arg requires one.""" 117 | args_using_extensions = {":copy": "copy", ":create": "mailbox"} 118 | if arg in args_using_extensions: 119 | self.require(args_using_extensions[arg]) 120 | 121 | def __gen_require_command(self) -> Union[commands.Command, None]: 122 | """Internal method to create a RequireCommand based on requirements 123 | 124 | Called just before this object is going to be dumped. 125 | """ 126 | if not len(self.requires): 127 | return None 128 | reqcmd = commands.get_command_instance("require") 129 | reqcmd.check_next_arg("stringlist", self.requires) 130 | return reqcmd 131 | 132 | def __quote_if_necessary(self, value: str) -> str: 133 | """Add double quotes to the given string if necessary 134 | 135 | :param value: the string to check 136 | :return: the string between quotes 137 | """ 138 | if not value.startswith(('"', "'")): 139 | return '"%s"' % value 140 | return value 141 | 142 | def __build_condition( 143 | self, condition: List[str], parent: commands.Command, tag: Optional[str] = None 144 | ) -> commands.Command: 145 | """Translate a condition to a valid sievelib Command. 146 | 147 | :param list condition: condition's definition 148 | :param ``Command`` parent: the parent 149 | :param str tag: tag to use instead of the one included into :keyword:`condition` 150 | :rtype: Command 151 | :return: the generated command 152 | """ 153 | if tag is None: 154 | tag = condition[1] 155 | cmd = commands.get_command_instance("header", parent) 156 | cmd.check_next_arg("tag", tag) 157 | if isinstance(condition[0], list): 158 | cmd.check_next_arg( 159 | "stringlist", [self.__quote_if_necessary(c) for c in condition[0]] 160 | ) 161 | else: 162 | cmd.check_next_arg("string", self.__quote_if_necessary(condition[0])) 163 | if isinstance(condition[2], list): 164 | cmd.check_next_arg( 165 | "stringlist", [self.__quote_if_necessary(c) for c in condition[2]] 166 | ) 167 | else: 168 | cmd.check_next_arg("string", self.__quote_if_necessary(condition[2])) 169 | return cmd 170 | 171 | def __create_filter( 172 | self, 173 | conditions: List[tuple], 174 | actions: List[tuple], 175 | matchtype: str = "anyof", 176 | ) -> commands.Command: 177 | """ 178 | Create a new filter. 179 | 180 | A filter is composed of: 181 | * a name 182 | * one or more conditions (tests) combined together using ``matchtype`` 183 | * one or more actions 184 | 185 | A condition must be given as a 3-uple of the form:: 186 | 187 | (test's name, operator, value) 188 | 189 | An action must be given as a 2-uple of the form:: 190 | 191 | (action's name, value) 192 | 193 | It uses the "header" test to generate the sieve syntax 194 | corresponding to the given conditions. 195 | 196 | :param conditions: the list of conditions 197 | :param actions: the list of actions 198 | :param matchtype: "anyof" or "allof" 199 | """ 200 | ifcontrol = commands.get_command_instance("if") 201 | mtypeobj = commands.get_command_instance(matchtype, ifcontrol) 202 | for c in conditions: 203 | if not isinstance(c[0], list) and c[0].startswith("not"): 204 | negate = True 205 | cname = c[0].replace("not", "", 1) 206 | else: 207 | negate = False 208 | cname = c[0] 209 | if cname in ("true", "false"): 210 | cmd = commands.get_command_instance(c[0], ifcontrol) 211 | elif cname == "size": 212 | cmd = commands.get_command_instance("size", ifcontrol) 213 | cmd.check_next_arg("tag", c[1]) 214 | cmd.check_next_arg("number", c[2]) 215 | elif cname == "exists": 216 | cmd = commands.get_command_instance("exists", ifcontrol) 217 | cmd.check_next_arg( 218 | "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[1:])) 219 | ) 220 | elif cname == "envelope": 221 | cmd = commands.get_command_instance("envelope", ifcontrol, False) 222 | self.require("envelope") 223 | if c[1].startswith(":not"): 224 | comp_tag = c[1].replace("not", "") 225 | negate = True 226 | else: 227 | comp_tag = c[1] 228 | cmd.check_next_arg("tag", comp_tag) 229 | cmd.check_next_arg( 230 | "stringlist", 231 | "[{}]".format(",".join('"{}"'.format(val) for val in c[2])), 232 | ) 233 | cmd.check_next_arg( 234 | "stringlist", 235 | "[{}]".format(",".join('"{}"'.format(val) for val in c[3])), 236 | ) 237 | elif cname == "address": 238 | cmd = commands.get_command_instance("address", ifcontrol, False) 239 | if c[1].startswith(":not"): 240 | comp_tag = c[1].replace("not", "") 241 | negate = True 242 | else: 243 | comp_tag = c[1] 244 | cmd.check_next_arg("tag", comp_tag) 245 | for arg in c[2:]: 246 | if isinstance(arg, str): 247 | finalarg = self.__quote_if_necessary(arg) 248 | else: 249 | finalarg = "[{}]".format( 250 | ",".join('"{}"'.format(val) for val in arg) 251 | ) 252 | cmd.check_next_arg("stringlist", finalarg) 253 | 254 | elif cname == "body": 255 | cmd = commands.get_command_instance("body", ifcontrol, False) 256 | self.require(cmd.extension) 257 | cmd.check_next_arg("tag", c[1]) 258 | if c[2].startswith(":not"): 259 | comp_tag = c[2].replace("not", "") 260 | negate = True 261 | else: 262 | comp_tag = c[2] 263 | cmd.check_next_arg("tag", comp_tag) 264 | cmd.check_next_arg( 265 | "stringlist", "[%s]" % (",".join('"%s"' % val for val in c[3:])) 266 | ) 267 | elif cname == "currentdate": 268 | cmd = commands.get_command_instance("currentdate", ifcontrol, False) 269 | self.require(cmd.extension) 270 | cmd.check_next_arg("tag", c[1]) 271 | cmd.check_next_arg("string", self.__quote_if_necessary(c[2])) 272 | if c[3].startswith(":not"): 273 | comp_tag = c[3].replace("not", "") 274 | negate = True 275 | else: 276 | comp_tag = c[3] 277 | cmd.check_next_arg("tag", comp_tag, check_extension=False) 278 | next_arg_pos = 4 279 | if comp_tag == ":value": 280 | self.require("relational") 281 | cmd.check_next_arg( 282 | "string", self.__quote_if_necessary(c[next_arg_pos]) 283 | ) 284 | next_arg_pos += 1 285 | cmd.check_next_arg("string", self.__quote_if_necessary(c[next_arg_pos])) 286 | next_arg_pos += 1 287 | cmd.check_next_arg( 288 | "stringlist", 289 | "[%s]" % (",".join('"%s"' % val for val in c[next_arg_pos:])), 290 | ) 291 | else: 292 | # header command fallback 293 | if c[1].startswith(":not"): 294 | cmd = self.__build_condition( 295 | c, ifcontrol, c[1].replace("not", "", 1) 296 | ) 297 | negate = True 298 | else: 299 | cmd = self.__build_condition(c, ifcontrol) 300 | if negate: 301 | not_cmd = commands.get_command_instance("not", ifcontrol) 302 | not_cmd.check_next_arg("test", cmd) 303 | cmd = not_cmd 304 | mtypeobj.check_next_arg("test", cmd) 305 | ifcontrol.check_next_arg("test", mtypeobj) 306 | 307 | for actdef in actions: 308 | action = commands.get_command_instance(actdef[0], ifcontrol, False) 309 | if action.extension is not None: 310 | self.require(action.extension) 311 | for arg in actdef[1:]: 312 | self.check_if_arg_is_extension(arg) 313 | if isinstance(arg, int): 314 | atype = "number" 315 | elif isinstance(arg, list): 316 | atype = "stringlist" 317 | elif arg.startswith(":"): 318 | atype = "tag" 319 | else: 320 | atype = "string" 321 | arg = self.__quote_if_necessary(arg) 322 | action.check_next_arg(atype, arg, check_extension=False) 323 | ifcontrol.addchild(action) 324 | return ifcontrol 325 | 326 | def _unicode_filter_name(self, name) -> str: 327 | """Convert name to unicode if necessary.""" 328 | return name.decode("utf-8") if isinstance(name, bytes) else name 329 | 330 | def filter_exists(self, name: str) -> bool: 331 | """Check if a filter with name already exists.""" 332 | for existing_filter in self.filters: 333 | if existing_filter["name"] == name: 334 | return True 335 | return False 336 | 337 | def addfilter( 338 | self, 339 | name: str, 340 | conditions: List[tuple], 341 | actions: List[tuple], 342 | matchtype: str = "anyof", 343 | ) -> None: 344 | """Add a new filter to this filters set 345 | 346 | :param name: the filter's name 347 | :param conditions: the list of conditions 348 | :param actions: the list of actions 349 | :param matchtype: "anyof" or "allof" 350 | """ 351 | name = self._unicode_filter_name(name) 352 | if self.filter_exists(name): 353 | raise FilterAlreadyExists 354 | ifcontrol = self.__create_filter(conditions, actions, matchtype) 355 | self.filters += [ 356 | { 357 | "name": name, 358 | "content": ifcontrol, 359 | "enabled": True, 360 | } 361 | ] 362 | 363 | def updatefilter( 364 | self, 365 | oldname: str, 366 | newname: str, 367 | conditions: List[tuple], 368 | actions: List[tuple], 369 | matchtype: str = "anyof", 370 | ) -> bool: 371 | """Update a specific filter 372 | 373 | Instead of removing and re-creating the filter, we update the 374 | content in order to keep the original order between filters. 375 | 376 | :param oldname: the filter's current name 377 | :param newname: the filter's new name 378 | :param conditions: the list of conditions 379 | :param actions: the list of actions 380 | :param matchtype: "anyof" or "allof" 381 | """ 382 | filter_def = None 383 | oldname = self._unicode_filter_name(oldname) 384 | for f in self.filters: 385 | if f["name"] == oldname: 386 | filter_def = f 387 | break 388 | if not filter_def: 389 | return False 390 | newname = self._unicode_filter_name(newname) 391 | if newname != oldname and self.filter_exists(newname): 392 | raise FilterAlreadyExists 393 | filter_def["name"] = newname 394 | filter_def["content"] = self.__create_filter(conditions, actions, matchtype) 395 | if not filter_def["enabled"]: 396 | return self.disablefilter(newname) 397 | return True 398 | 399 | def replacefilter( 400 | self, 401 | oldname: str, 402 | sieve_filter: commands.Command, 403 | newname: Optional[str] = None, 404 | description: Optional[str] = None, 405 | ) -> bool: 406 | """replace a specific sieve_filter 407 | 408 | Instead of removing and re-creating the sieve_filter, we update the 409 | content in order to keep the original order between filters. 410 | 411 | :param oldname: the sieve_filter's current name 412 | :param newname: the sieve_filter's new name 413 | :param sieve_filter: the sieve_filter object as get from 414 | FiltersSet.getfilter() 415 | """ 416 | filter_def = None 417 | oldname = self._unicode_filter_name(oldname) 418 | for f in self.filters: 419 | if f["name"] == oldname: 420 | filter_def = f 421 | break 422 | if not filter_def: 423 | return False 424 | if newname is None: 425 | newname = oldname 426 | newname = self._unicode_filter_name(newname) 427 | if newname != oldname and self.filter_exists(newname): 428 | raise FilterAlreadyExists 429 | filter_def["name"] = newname 430 | filter_def["content"] = sieve_filter 431 | if description is not None: 432 | filter_def["description"] = description 433 | if not filter_def["enabled"]: 434 | return self.disablefilter(newname) 435 | return True 436 | 437 | def getfilter(self, name: str) -> Union[commands.Command, None]: 438 | """Search for a specific filter 439 | 440 | :param name: the filter's name 441 | :return: the Command object if found, None otherwise 442 | """ 443 | name = self._unicode_filter_name(name) 444 | for f in self.filters: 445 | if f["name"] == name: 446 | if not f["enabled"]: 447 | return f["content"].children[0] 448 | return f["content"] 449 | return None 450 | 451 | def get_filter_matchtype(self, name: str) -> Union[str, None]: 452 | """Retrieve matchtype of the given filter.""" 453 | flt = self.getfilter(name) 454 | if not flt: 455 | return None 456 | for node in flt.walk(): 457 | if isinstance(node, (commands.AllofCommand, commands.AnyofCommand)): 458 | return node.__class__.__name__.lower().replace("command", "") 459 | return None 460 | 461 | def get_filter_conditions(self, name: str) -> Union[List[str], None]: 462 | """Retrieve conditions of the given filter.""" 463 | flt = self.getfilter(name) 464 | if not flt: 465 | return None 466 | conditions = [] 467 | negate = False 468 | for node in flt.walk(): 469 | if isinstance(node, commands.NotCommand): 470 | negate = True 471 | elif isinstance( 472 | node, 473 | ( 474 | commands.HeaderCommand, 475 | commands.SizeCommand, 476 | commands.ExistsCommand, 477 | commands.BodyCommand, 478 | commands.EnvelopeCommand, 479 | commands.CurrentdateCommand, 480 | ), 481 | ): 482 | args = node.args_as_tuple() 483 | if negate: 484 | if node.name in ["header", "envelope"]: 485 | nargs = (args[0], ":not{}".format(args[1][1:])) 486 | if len(args) > 3: 487 | nargs += args[2:] 488 | else: 489 | nargs += (args[2],) 490 | args = nargs 491 | elif node.name == "body": 492 | args = args[:2] + (":not{}".format(args[2][1:]),) + args[3:] 493 | elif node.name == "currentdate": 494 | args = args[:3] + (":not{}".format(args[3][1:]),) + args[4:] 495 | elif node.name == "exists": 496 | args = ("not{}".format(args[0]),) + args[1:] 497 | negate = False 498 | conditions.append(args) 499 | return conditions 500 | 501 | def get_filter_actions(self, name: str) -> Union[List[str], None]: 502 | """Retrieve actions of the given filter.""" 503 | flt = self.getfilter(name) 504 | if not flt: 505 | return None 506 | actions: list = [] 507 | for node in flt.walk(): 508 | if isinstance(node, commands.ActionCommand): 509 | actions.append(node.args_as_tuple()) 510 | return actions 511 | 512 | def removefilter(self, name: str) -> bool: 513 | """Remove a specific filter 514 | 515 | :param name: the filter's name 516 | """ 517 | name = self._unicode_filter_name(name) 518 | for f in self.filters: 519 | if f["name"] == name: 520 | self.filters.remove(f) 521 | return True 522 | return False 523 | 524 | def enablefilter(self, name: str) -> bool: 525 | """Enable a filter 526 | 527 | Just removes the "if false" test surrouding this filter. 528 | 529 | :param name: the filter's name 530 | """ 531 | name = self._unicode_filter_name(name) 532 | for f in self.filters: 533 | if f["name"] != name: 534 | continue 535 | if not self.__isdisabled(f["content"]): 536 | return False 537 | f["content"] = f["content"].children[0] 538 | f["enabled"] = True 539 | return True 540 | return False # raise NotFound 541 | 542 | def is_filter_disabled(self, name: str) -> bool: 543 | """Tells if the filter is currently disabled or not 544 | 545 | :param name: the filter's name 546 | """ 547 | name = self._unicode_filter_name(name) 548 | for f in self.filters: 549 | if f["name"] == name: 550 | return self.__isdisabled(f["content"]) 551 | return True 552 | 553 | def disablefilter(self, name: str) -> bool: 554 | """Disable a filter 555 | 556 | Instead of commenting the filter, we just surround it with a 557 | "if false { }" test. 558 | 559 | :param name: the filter's name 560 | :return: True if filter was disabled, False otherwise 561 | """ 562 | name = self._unicode_filter_name(name) 563 | ifcontrol = commands.get_command_instance("if") 564 | falsecmd = commands.get_command_instance("false", ifcontrol) 565 | ifcontrol.check_next_arg("test", falsecmd) 566 | for f in self.filters: 567 | if f["name"] != name: 568 | continue 569 | ifcontrol.addchild(f["content"]) 570 | f["content"] = ifcontrol 571 | f["enabled"] = False 572 | return True 573 | return False 574 | 575 | def movefilter(self, name: str, direction: str) -> bool: 576 | """Moves the filter up or down 577 | 578 | :param name: the filter's name 579 | :param direction: string "up" or "down" 580 | """ 581 | name = self._unicode_filter_name(name) 582 | cpt = 0 583 | for f in self.filters: 584 | if f["name"] == name: 585 | if direction == "up": 586 | if cpt == 0: 587 | return False 588 | self.filters.remove(f) 589 | self.filters.insert(cpt - 1, f) 590 | return True 591 | if cpt == len(self.filters) - 1: 592 | return False 593 | self.filters.remove(f) 594 | self.filters.insert(cpt + 1, f) 595 | return True 596 | cpt += 1 597 | return False # raise not found 598 | 599 | def dump(self, target=sys.stdout): 600 | """Dump this object 601 | 602 | Available for debugging purposes 603 | """ 604 | print("Dumping filters set %s\n" % self.name) 605 | cmd = self.__gen_require_command() 606 | if cmd: 607 | print("Dumping requirements") 608 | cmd.dump(target=target) 609 | target.write("\n") 610 | 611 | for f in self.filters: 612 | target.write("Filter Name: %s\n" % f["name"]) 613 | if "description" in f: 614 | target.write("Filter Description: %s\n" % f["description"]) 615 | f["content"].dump(target=target) 616 | 617 | def tosieve(self, target=sys.stdout): 618 | """Generate the sieve syntax corresponding to this filters set 619 | 620 | This method will usually be called when this filters set is 621 | done. The default is to print the sieve syntax on the standard 622 | output. You can pass an opened file pointer object if you want 623 | to write the content elsewhere. 624 | 625 | :param target: file pointer where the sieve syntax will be printed 626 | """ 627 | cmd = self.__gen_require_command() 628 | if cmd: 629 | cmd.tosieve(target=target) 630 | target.write("\n") 631 | for f in self.filters: 632 | target.write("{}{}\n".format(self.filter_name_pretext, f["name"])) 633 | if "description" in f and f["description"]: 634 | target.write( 635 | "{}{}\n".format(self.filter_desc_pretext, f["description"]) 636 | ) 637 | f["content"].tosieve(target=target) 638 | 639 | 640 | if __name__ == "__main__": 641 | fs = FiltersSet("test") 642 | 643 | fs.addfilter( 644 | "rule1", 645 | [ 646 | ("Sender", ":is", "toto@toto.com"), 647 | ], 648 | [ 649 | ("fileinto", "Toto"), 650 | ], 651 | ) 652 | fs.tosieve() 653 | -------------------------------------------------------------------------------- /sievelib/managesieve.py: -------------------------------------------------------------------------------- 1 | """ 2 | A MANAGESIEVE client. 3 | 4 | A protocol for securely managing Sieve scripts on a remote server. 5 | This protocol allows a user to have multiple scripts, and also alerts 6 | a user to syntactically flawed scripts. 7 | 8 | Implementation based on RFC 5804. 9 | """ 10 | 11 | import base64 12 | import re 13 | import socket 14 | import ssl 15 | from typing import Any, List, Optional, Tuple 16 | 17 | from .digest_md5 import DigestMD5 18 | from . import tools 19 | 20 | 21 | CRLF = b"\r\n" 22 | 23 | KNOWN_CAPABILITIES = [ 24 | "IMPLEMENTATION", 25 | "SASL", 26 | "SIEVE", 27 | "STARTTLS", 28 | "NOTIFY", 29 | "LANGUAGE", 30 | "VERSION", 31 | ] 32 | 33 | SUPPORTED_AUTH_MECHS = ["DIGEST-MD5", "PLAIN", "LOGIN", "OAUTHBEARER"] 34 | 35 | 36 | class Error(Exception): 37 | pass 38 | 39 | 40 | class Response(Exception): 41 | def __init__(self, code: bytes, data: bytes): 42 | self.code = code 43 | self.data = data 44 | 45 | def __str__(self): 46 | return "%s %s" % (self.code, self.data) 47 | 48 | 49 | class Literal(Exception): 50 | def __init__(self, value): 51 | self.value = value 52 | 53 | def __str__(self): 54 | return "{%d}" % self.value 55 | 56 | 57 | def authentication_required(meth): 58 | """Simple class method decorator. 59 | 60 | Checks if the client is currently connected. 61 | 62 | :param meth: the original called method 63 | """ 64 | 65 | def check(cls, *args, **kwargs): 66 | if cls.authenticated: 67 | return meth(cls, *args, **kwargs) 68 | raise Error("Authentication required") 69 | 70 | return check 71 | 72 | 73 | class Client: 74 | read_size = 4096 75 | read_timeout = 5 76 | 77 | def __init__(self, srvaddr: str, srvport: int = 4190, debug: bool = False): 78 | self.srvaddr = srvaddr 79 | self.srvport = srvport 80 | self.__debug = debug 81 | self.sock: socket.socket = None 82 | self.__read_buffer: bytes = b"" 83 | self.authenticated: bool = False 84 | self.errcode: bytes = None 85 | self.errmsg: bytes = b"" 86 | 87 | self.__capabilities: dict[str, str] = {} 88 | self.__respcode_expr = re.compile(rb"(OK|NO|BYE)\s*(.+)?") 89 | self.__error_expr = re.compile(rb'(\([\w/-]+\))?\s*(".+")') 90 | self.__size_expr = re.compile(rb"\{(\d+)\+?\}") 91 | self.__active_expr = re.compile(rb"ACTIVE", re.IGNORECASE) 92 | 93 | def __del__(self): 94 | if self.sock is not None: 95 | self.sock.close() 96 | self.sock = None 97 | 98 | def __dprint(self, message): 99 | if not self.__debug: 100 | return 101 | print("DEBUG: %s" % message) 102 | 103 | def __read_block(self, size: int) -> bytes: 104 | """Read a block of 'size' bytes from the server. 105 | 106 | An internal buffer is used to read data from the server. If 107 | enough data is available from it, we return that data. 108 | 109 | Eventually, we try to grab the missing part from the server 110 | for Client.read_timeout seconds. If no data can be 111 | retrieved, it is considered as a fatal error and an 'Error' 112 | exception is raised. 113 | 114 | :param size: number of bytes to read 115 | :rtype: string 116 | :returns: the read block (can be empty) 117 | """ 118 | buf = b"" 119 | if len(self.__read_buffer): 120 | limit = size if size <= len(self.__read_buffer) else len(self.__read_buffer) 121 | buf = self.__read_buffer[:limit] 122 | self.__read_buffer = self.__read_buffer[limit:] 123 | size -= limit 124 | if not size: 125 | return buf 126 | try: 127 | buf += self.sock.recv(size) 128 | except (socket.timeout, ssl.SSLError): 129 | raise Error("Failed to read %d bytes from the server" % size) 130 | self.__dprint(buf) 131 | return buf 132 | 133 | def __read_line(self) -> bytes: 134 | """Read one line from the server. 135 | 136 | An internal buffer is used to read data from the server 137 | (blocks of Client.read_size bytes). If the buffer 138 | is not empty, we try to find an entire line to return. 139 | 140 | If we failed, we try to read new content from the server for 141 | Client.read_timeout seconds. If no data can be 142 | retrieved, it is considered as a fatal error and an 'Error' 143 | exception is raised. 144 | 145 | :rtype: string 146 | :return: the read line 147 | """ 148 | ret = b"" 149 | while True: 150 | try: 151 | pos = self.__read_buffer.index(CRLF) 152 | ret = self.__read_buffer[:pos] 153 | self.__read_buffer = self.__read_buffer[pos + len(CRLF) :] 154 | break 155 | except ValueError: 156 | pass 157 | try: 158 | nval = self.sock.recv(self.read_size) 159 | self.__dprint(nval) 160 | if not len(nval): 161 | break 162 | self.__read_buffer += nval 163 | except (socket.timeout, ssl.SSLError): 164 | raise Error("Failed to read data from the server") 165 | 166 | if len(ret): 167 | m = self.__size_expr.match(ret) 168 | if m: 169 | raise Literal(int(m.group(1))) 170 | 171 | m = self.__respcode_expr.match(ret) 172 | if m: 173 | if m.group(1) == b"BYE": 174 | raise Error("Connection closed by server") 175 | if m.group(1) == b"NO": 176 | self.__parse_error(m.group(2)) 177 | raise Response(m.group(1), m.group(2)) 178 | return ret 179 | 180 | def __read_response(self, nblines: int = -1) -> Tuple[bytes, bytes, bytes]: 181 | """Read a response from the server. 182 | 183 | In the usual case, we read lines until we find one that looks 184 | like a response (OK|NO|BYE\s*(.+)?). 185 | 186 | If *nblines* > 0, we read excactly nblines before returning. 187 | 188 | :param nblines: number of lines to read (default : -1) 189 | :rtype: tuple 190 | :return: a tuple of the form (code, data, response). If 191 | nblines is provided, code and data can be equal to None. 192 | """ 193 | resp, code, data = (b"", None, None) 194 | cpt = 0 195 | while True: 196 | try: 197 | line = self.__read_line() 198 | except Response as inst: 199 | code = inst.code 200 | data = inst.data 201 | break 202 | except Literal as inst: 203 | resp += self.__read_block(inst.value) 204 | if not resp.endswith(CRLF): 205 | resp += self.__read_line() + CRLF 206 | continue 207 | if not len(line): 208 | continue 209 | resp += line + CRLF 210 | cpt += 1 211 | if nblines != -1 and cpt == nblines: 212 | break 213 | 214 | return (code, data, resp) 215 | 216 | def __prepare_args(self, args: List[Any]) -> List[bytes]: 217 | """Format command arguments before sending them. 218 | 219 | Command arguments of type string must be quoted, the only 220 | exception concerns size indication (of the form {\d\+?}). 221 | 222 | :param args: list of arguments 223 | :return: a list for transformed arguments 224 | """ 225 | ret = [] 226 | for a in args: 227 | if isinstance(a, bytes): 228 | if self.__size_expr.match(a): 229 | ret += [a] 230 | else: 231 | ret += [b'"' + a + b'"'] 232 | continue 233 | ret += [bytes(str(a).encode("utf-8"))] 234 | return ret 235 | 236 | def __prepare_content(self, content: str) -> bytes: 237 | """Format script content before sending it. 238 | 239 | Script length must be inserted before the content, 240 | enclosed in curly braces, separated by CRLF. 241 | 242 | :param content: script content as str or bytes 243 | :return: transformed script as bytes 244 | """ 245 | bcontent: bytes = content.encode("utf-8") 246 | return b"{%d+}%s%s" % (len(bcontent), CRLF, bcontent) 247 | 248 | def __send_command( 249 | self, 250 | name: str, 251 | args: Optional[List[bytes]] = None, 252 | withcontent: bool = False, 253 | extralines: Optional[List[bytes]] = None, 254 | nblines: int = -1, 255 | ) -> Tuple[str, str, bytes]: 256 | """Send a command to the server. 257 | 258 | If args is not empty, we concatenate the given command with 259 | the content of this list. If extralines is not empty, they are 260 | sent one by one to the server. (CLRF are automatically 261 | appended to them) 262 | 263 | We wait for a response just after the command has been sent. 264 | 265 | :param name: the command to sent 266 | :param args: a list of arguments for this command 267 | :param withcontent: tells the function to return the server's response 268 | or not 269 | :param extralines: a list of extra lines to sent after the command 270 | :param nblines: the number of response lines to read (all by default) 271 | 272 | :returns: a tuple of the form (code, data[, response]) 273 | 274 | """ 275 | tosend = name.encode("utf-8") 276 | if args: 277 | tosend += b" " + b" ".join(self.__prepare_args(args)) 278 | self.__dprint(b"Command: " + tosend) 279 | self.sock.sendall(tosend + CRLF) 280 | if extralines: 281 | for l in extralines: 282 | self.sock.sendall(l + CRLF) 283 | code, data, content = self.__read_response(nblines) 284 | 285 | if isinstance(code, bytes): 286 | code = code.decode("utf-8") 287 | if isinstance(data, bytes): 288 | data = data.decode("utf-8") 289 | 290 | if withcontent: 291 | return (code, data, content) 292 | return (code, data) 293 | 294 | def __get_capabilities(self) -> bool: 295 | code, data, capabilities = self.__read_response() 296 | if code == "NO": 297 | return False 298 | 299 | for l in capabilities.splitlines(): 300 | parts = l.split(None, 1) 301 | cname = parts[0].strip(b'"').decode("utf-8") 302 | if cname not in KNOWN_CAPABILITIES: 303 | continue 304 | self.__capabilities[cname] = ( 305 | parts[1].strip(b'"').decode("utf-8") if len(parts) > 1 else None 306 | ) 307 | return True 308 | 309 | def __parse_error(self, text: bytes): 310 | """Parse an error received from the server. 311 | 312 | if text corresponds to a size indication, we grab the 313 | remaining content from the server. 314 | 315 | Otherwise, we try to match an error of the form \(\w+\)?\s*".+" 316 | 317 | On succes, the two public members errcode and errmsg are 318 | filled with the parsing results. 319 | 320 | :param text: the response to parse 321 | """ 322 | m = self.__size_expr.match(text) 323 | if m is not None: 324 | self.errcode = b"" 325 | self.errmsg = self.__read_block(int(m.group(1)) + 2) 326 | return 327 | 328 | m = self.__error_expr.match(text) 329 | if m is None: 330 | raise Error("Bad error message") 331 | if m.group(1) is not None: 332 | self.errcode = m.group(1).strip(b"()") 333 | else: 334 | self.errcode = b"" 335 | self.errmsg = m.group(2).strip(b'"') 336 | 337 | def _plain_authentication( 338 | self, login: bytes, password: bytes, authz_id: bytes = b"" 339 | ) -> bool: 340 | """SASL PLAIN authentication 341 | 342 | :param login: username 343 | :param password: clear password 344 | :return: True on success, False otherwise. 345 | """ 346 | params = base64.b64encode(b"\0".join([authz_id, login, password])) 347 | code, data = self.__send_command("AUTHENTICATE", [b"PLAIN", params]) 348 | if code == "OK": 349 | return True 350 | return False 351 | 352 | def _login_authentication( 353 | self, login: bytes, password: bytes, authz_id: bytes = "" 354 | ) -> bool: 355 | """SASL LOGIN authentication 356 | 357 | :param login: username 358 | :param password: clear password 359 | :return: True on success, False otherwise. 360 | """ 361 | extralines = [ 362 | b'"%s"' % base64.b64encode(login), 363 | b'"%s"' % base64.b64encode(password), 364 | ] 365 | code, data = self.__send_command( 366 | "AUTHENTICATE", [b"LOGIN"], extralines=extralines 367 | ) 368 | if code == "OK": 369 | return True 370 | return False 371 | 372 | def _digest_md5_authentication( 373 | self, login: bytes, password: bytes, authz_id: bytes = b"" 374 | ) -> bool: 375 | """SASL DIGEST-MD5 authentication 376 | 377 | :param login: username 378 | :param password: clear password 379 | :return: True on success, False otherwise. 380 | """ 381 | code, data, challenge = self.__send_command( 382 | "AUTHENTICATE", [b"DIGEST-MD5"], withcontent=True, nblines=1 383 | ) 384 | dmd5 = DigestMD5(challenge, "sieve/%s" % self.srvaddr) 385 | 386 | code, data, challenge = self.__send_command( 387 | '"%s"' % dmd5.response(login, password, authz_id), 388 | withcontent=True, 389 | nblines=1, 390 | ) 391 | if not challenge: 392 | return False 393 | if not dmd5.check_last_challenge(login, password, challenge): 394 | self.errmsg = "Bad challenge received from server" 395 | return False 396 | code, data = self.__send_command('""') 397 | if code == "OK": 398 | return True 399 | return False 400 | 401 | def _oauthbearer_authentication( 402 | self, login: bytes, password: bytes, authz_id: bytes = b"" 403 | ) -> bool: 404 | """ 405 | OAUTHBEARER authentication. 406 | 407 | :param login: username 408 | :param password: clear password 409 | :return: True on success, False otherwise. 410 | """ 411 | if isinstance(login, str): 412 | login = login.encode("utf-8") 413 | if isinstance(password, str): 414 | password = password.encode("utf-8") 415 | token = b"n,a=" + login + b",\001auth=Bearer " + password + b"\001\001" 416 | token = base64.b64encode(token) 417 | code, data = self.__send_command("AUTHENTICATE", [b"OAUTHBEARER", token]) 418 | if code == "OK": 419 | return True 420 | return False 421 | 422 | def __authenticate( 423 | self, 424 | login: str, 425 | password: str, 426 | authz_id: str = "", 427 | authmech: Optional[str] = None, 428 | ) -> bool: 429 | """AUTHENTICATE command 430 | 431 | Actually, it is just a wrapper to the real commands (one by 432 | mechanism). We try all supported mechanisms (from the 433 | strongest to the weakest) until we find one supported by the 434 | server. 435 | 436 | Then we try to authenticate (only once). 437 | 438 | :param login: username 439 | :param password: clear password 440 | :param authz_id: authorization ID 441 | :param authmech: prefered authentication mechanism 442 | :return: True on success, False otherwise 443 | """ 444 | if "SASL" not in self.__capabilities: 445 | raise Error("SASL not supported by the server") 446 | srv_mechanisms = self.get_sasl_mechanisms() 447 | 448 | if authmech is None or authmech not in SUPPORTED_AUTH_MECHS: 449 | mech_list = SUPPORTED_AUTH_MECHS 450 | else: 451 | mech_list = [authmech] 452 | 453 | for mech in mech_list: 454 | if mech not in srv_mechanisms: 455 | continue 456 | mech = mech.lower().replace("-", "_") 457 | auth_method = getattr(self, "_%s_authentication" % mech) 458 | if auth_method( 459 | login.encode("utf-8"), 460 | password.encode("utf-8"), 461 | authz_id.encode("utf-8"), 462 | ): 463 | self.authenticated = True 464 | return True 465 | return False 466 | 467 | self.errmsg = b"No suitable mechanism found" 468 | return False 469 | 470 | def __starttls(self, keyfile=None, certfile=None) -> bool: 471 | """STARTTLS command 472 | 473 | See MANAGESIEVE specifications, section 2.2. 474 | 475 | :param keyfile: an eventual private key to use 476 | :param certfile: an eventual certificate to use 477 | :rtype: boolean 478 | """ 479 | if not self.has_tls_support(): 480 | raise Error("STARTTLS not supported by the server") 481 | code, data = self.__send_command("STARTTLS") 482 | if code != "OK": 483 | return False 484 | context = ssl.create_default_context() 485 | if certfile is not None: 486 | context.load_cert_chain(certfile, keyfile=keyfile) 487 | try: 488 | # nsock = ssl.wrap_socket(self.sock, keyfile, certfile) 489 | nsock = context.wrap_socket(self.sock, server_hostname=self.srvaddr) 490 | except ssl.SSLError as e: 491 | raise Error("SSL error: %s" % str(e)) 492 | self.sock = nsock 493 | self.__capabilities = {} 494 | self.__get_capabilities() 495 | return True 496 | 497 | def get_implementation(self) -> str: 498 | """Returns the IMPLEMENTATION value. 499 | 500 | It is read from server capabilities. (see the CAPABILITY 501 | command) 502 | 503 | :rtype: string 504 | """ 505 | return self.__capabilities["IMPLEMENTATION"] 506 | 507 | def get_sasl_mechanisms(self) -> List[str]: 508 | """Returns the supported authentication mechanisms. 509 | 510 | They're read from server capabilities. (see the CAPABILITY 511 | command) 512 | 513 | :rtype: list of string 514 | """ 515 | return self.__capabilities["SASL"].split() 516 | 517 | def has_tls_support(self) -> bool: 518 | """Tells if the server has STARTTLS support or not. 519 | 520 | It is read from server capabilities. (see the CAPABILITY 521 | command) 522 | 523 | :rtype: boolean 524 | """ 525 | return "STARTTLS" in self.__capabilities 526 | 527 | def get_sieve_capabilities(self): 528 | """Returns the SIEVE extensions supported by the server. 529 | 530 | They're read from server capabilities. (see the CAPABILITY 531 | command) 532 | 533 | :rtype: string 534 | """ 535 | if isinstance(self.__capabilities["SIEVE"], str): 536 | self.__capabilities["SIEVE"] = self.__capabilities["SIEVE"].split() 537 | return self.__capabilities["SIEVE"] 538 | 539 | def connect( 540 | self, 541 | login: str, 542 | password: str, 543 | authz_id: str = "", 544 | starttls: bool = False, 545 | authmech: Optional[str] = None, 546 | ): 547 | """Establish a connection with the server. 548 | 549 | This function must be used. It read the server capabilities 550 | and wraps calls to STARTTLS and AUTHENTICATE commands. 551 | 552 | :param login: username 553 | :param password: clear password 554 | :param starttls: use a TLS connection or not 555 | :param authmech: prefered authenticate mechanism 556 | :rtype: boolean 557 | """ 558 | try: 559 | self.sock = socket.create_connection((self.srvaddr, self.srvport)) 560 | self.sock.settimeout(Client.read_timeout) 561 | except socket.error as msg: 562 | raise Error("Connection to server failed: %s" % str(msg)) 563 | 564 | if not self.__get_capabilities(): 565 | raise Error("Failed to read capabilities from server") 566 | if starttls and not self.__starttls(): 567 | return False 568 | if self.__authenticate(login, password, authz_id, authmech): 569 | return True 570 | return False 571 | 572 | def logout(self): 573 | """Disconnect from the server 574 | 575 | See MANAGESIEVE specifications, section 2.3 576 | """ 577 | self.__send_command("LOGOUT") 578 | 579 | def capability(self): 580 | """Ask server capabilities. 581 | 582 | See MANAGESIEVE specifications, section 2.4 This command does 583 | not affect capabilities recorded by this client. 584 | 585 | :rtype: string 586 | """ 587 | code, data, capabilities = self.__send_command("CAPABILITY", withcontent=True) 588 | if code == "OK": 589 | return capabilities 590 | return None 591 | 592 | @authentication_required 593 | def havespace(self, scriptname: str, scriptsize: int) -> bool: 594 | """Ask for available space. 595 | 596 | See MANAGESIEVE specifications, section 2.5 597 | 598 | :param scriptname: script's name 599 | :param scriptsize: script's size 600 | :rtype: boolean 601 | """ 602 | code, data = self.__send_command( 603 | "HAVESPACE", [scriptname.encode("utf-8"), scriptsize] 604 | ) 605 | if code == "OK": 606 | return True 607 | return False 608 | 609 | @authentication_required 610 | def listscripts(self) -> Tuple[str, List[str]]: 611 | """List available scripts. 612 | 613 | See MANAGESIEVE specifications, section 2.7 614 | 615 | :returns: a 2-uple (active script, [script1, ...]) 616 | """ 617 | code, data, listing = self.__send_command("LISTSCRIPTS", withcontent=True) 618 | if code == "NO": 619 | return None 620 | ret: List[str] = [] 621 | active_script: str = None 622 | for l in listing.splitlines(): 623 | if self.__size_expr.match(l): 624 | continue 625 | m = re.match(rb'"([^"]+)"\s*(.+)', l) 626 | if m is None: 627 | ret += [l.strip(b'"').decode("utf-8")] 628 | continue 629 | script = m.group(1).decode("utf-8") 630 | if self.__active_expr.match(m.group(2)): 631 | active_script = script 632 | continue 633 | ret += [script] 634 | self.__dprint(ret) 635 | return (active_script, ret) 636 | 637 | @authentication_required 638 | def getscript(self, name: str) -> str: 639 | """ 640 | Download a script from the server. 641 | 642 | See MANAGESIEVE specifications, section 2.9 643 | 644 | :param name: script's name 645 | :rtype: string 646 | :returns: the script's content on succes, None otherwise 647 | """ 648 | code, data, content = self.__send_command( 649 | "GETSCRIPT", [name.encode("utf-8")], withcontent=True 650 | ) 651 | if code == "OK": 652 | lines = content.splitlines() 653 | if self.__size_expr.match(lines[0]) is not None: 654 | lines = lines[1:] 655 | return "\n".join([line.decode("utf-8") for line in lines]) 656 | return None 657 | 658 | @authentication_required 659 | def putscript(self, name: str, content: str) -> bool: 660 | """Upload a script to the server 661 | 662 | See MANAGESIEVE specifications, section 2.6 663 | 664 | :param name: script's name 665 | :param content: script's content 666 | :rtype: boolean 667 | """ 668 | bcontent = self.__prepare_content(content) 669 | code, data = self.__send_command("PUTSCRIPT", [name.encode("utf-8"), bcontent]) 670 | if code == "OK": 671 | return True 672 | return False 673 | 674 | @authentication_required 675 | def deletescript(self, name: str) -> bool: 676 | """Delete a script from the server 677 | 678 | See MANAGESIEVE specifications, section 2.10 679 | 680 | :param name: script's name 681 | :rtype: boolean 682 | """ 683 | code, data = self.__send_command("DELETESCRIPT", [name.encode("utf-8")]) 684 | if code == "OK": 685 | return True 686 | return False 687 | 688 | @authentication_required 689 | def renamescript(self, oldname: str, newname: str) -> bool: 690 | """Rename a script on the server 691 | 692 | See MANAGESIEVE specifications, section 2.11.1 693 | 694 | As this command is optional, we emulate it if the server does 695 | not support it. 696 | 697 | :param oldname: current script's name 698 | :param newname: new script's name 699 | :rtype: boolean 700 | """ 701 | if "VERSION" in self.__capabilities: 702 | code, data = self.__send_command( 703 | "RENAMESCRIPT", [oldname.encode("utf-8"), newname.encode("utf-8")] 704 | ) 705 | if code == "OK": 706 | return True 707 | return False 708 | 709 | (active_script, scripts) = self.listscripts() 710 | condition = oldname != active_script and ( 711 | scripts is None or oldname not in scripts 712 | ) 713 | if condition: 714 | self.errmsg = b"Old script does not exist" 715 | return False 716 | if newname in scripts: 717 | self.errmsg = b"New script already exists" 718 | return False 719 | oldscript = self.getscript(oldname) 720 | if oldscript is None: 721 | return False 722 | if not self.putscript(newname, oldscript): 723 | return False 724 | if active_script == oldname: 725 | if not self.setactive(newname): 726 | return False 727 | if not self.deletescript(oldname): 728 | return False 729 | return True 730 | 731 | @authentication_required 732 | def setactive(self, scriptname: str) -> bool: 733 | """Define the active script 734 | 735 | See MANAGESIEVE specifications, section 2.8 736 | 737 | If scriptname is empty, the current active script is disabled, 738 | ie. there will be no active script anymore. 739 | 740 | :param scriptname: script's name 741 | :rtype: boolean 742 | """ 743 | code, data = self.__send_command("SETACTIVE", [scriptname.encode("utf-8")]) 744 | if code == "OK": 745 | return True 746 | return False 747 | 748 | @authentication_required 749 | def checkscript(self, content: str) -> bool: 750 | """Check whether a script is valid 751 | 752 | See MANAGESIEVE specifications, section 2.12 753 | 754 | :param name: script's content 755 | :rtype: boolean 756 | """ 757 | if "VERSION" not in self.__capabilities: 758 | raise NotImplementedError("server does not support CHECKSCRIPT command") 759 | bcontent = self.__prepare_content(content) 760 | code, data = self.__send_command("CHECKSCRIPT", [bcontent]) 761 | if code == "OK": 762 | return True 763 | return False 764 | -------------------------------------------------------------------------------- /sievelib/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | This module provides a simple but functional parser for the SIEVE 5 | language used to filter emails. 6 | 7 | This implementation is based on RFC 5228 (http://tools.ietf.org/html/rfc5228) 8 | 9 | """ 10 | import re 11 | import sys 12 | from typing import Iterator, Tuple 13 | 14 | from sievelib.commands import get_command_instance, CommandError, RequireCommand 15 | 16 | 17 | class ParseError(Exception): 18 | """Generic parsing error""" 19 | 20 | def __init__(self, msg: str): 21 | self.msg = msg 22 | 23 | def __str__(self): 24 | return f"parsing error: {self.msg}" 25 | 26 | 27 | class Lexer: 28 | """ 29 | The lexical analysis part. 30 | 31 | This class provides a simple way to define tokens (with patterns) 32 | to be detected. 33 | 34 | Patterns are provided into a list of 2-uple. Each 2-uple consists 35 | of a token name and an associated pattern, example: 36 | 37 | [(b"left_bracket", br'\['),] 38 | """ 39 | 40 | def __init__(self, definitions): 41 | self.definitions = definitions 42 | parts = [] 43 | for name, part in definitions: 44 | param = b"(?P<%s>%s)" % (name, part) 45 | parts.append(param) 46 | self.regexpString = b"|".join(parts) 47 | self.regexp = re.compile(self.regexpString, re.MULTILINE) 48 | self.wsregexp = re.compile(rb"\s+", re.M) 49 | 50 | def curlineno(self) -> int: 51 | """Return the current line number""" 52 | return self.text[: self.pos].count(b"\n") + 1 53 | 54 | def curcolno(self) -> int: 55 | """Return the current column number""" 56 | return self.pos - self.text.rfind(b"\n", 0, self.pos) 57 | 58 | def scan(self, text: bytes) -> Iterator[Tuple[str, bytes]]: 59 | """Analyse some data 60 | 61 | Analyse the passed content. Each time a token is recognized, a 62 | 2-uple containing its name and parsed value is raised (via 63 | yield). 64 | 65 | On error, a ParseError exception is raised. 66 | 67 | :param text: a binary string containing the data to parse 68 | """ 69 | self.pos = 0 70 | self.text = text 71 | while self.pos < len(text): 72 | m = self.wsregexp.match(text, self.pos) 73 | if m is not None: 74 | self.pos = m.end() 75 | continue 76 | 77 | m = self.regexp.match(text, self.pos) 78 | if m is None: 79 | token = text[self.pos :] 80 | m = self.wsregexp.search(token) 81 | if m is not None: 82 | token = token[: m.start()] 83 | raise ParseError(f"unknown token {token}") 84 | 85 | yield (m.lastgroup, m.group(m.lastgroup)) 86 | self.pos += len(m.group(0)) 87 | 88 | 89 | class Parser: 90 | """The grammatical analysis part. 91 | 92 | Here we define the SIEVE language tokens and grammar. This class 93 | works with a Lexer object in order to check for grammar validity. 94 | """ 95 | 96 | lrules = [ 97 | (b"left_bracket", rb"\["), 98 | (b"right_bracket", rb"\]"), 99 | (b"left_parenthesis", rb"\("), 100 | (b"right_parenthesis", rb"\)"), 101 | (b"left_cbracket", rb"{"), 102 | (b"right_cbracket", rb"}"), 103 | (b"semicolon", rb";"), 104 | (b"comma", rb","), 105 | (b"hash_comment", rb"#.*$"), 106 | (b"bracket_comment", rb"/\*[\s\S]*?\*/"), 107 | (b"multiline", rb"text:[^$]*?[\r\n]+\.$"), 108 | (b"string", rb'"([^"\\]|\\.)*"'), 109 | (b"identifier", rb"[a-zA-Z_][\w]*"), 110 | (b"tag", rb":[a-zA-Z_][\w]*"), 111 | (b"number", rb"[0-9]+[KMGkmg]?"), 112 | ] 113 | 114 | def __init__(self, debug: bool = False): 115 | self.debug = debug 116 | self.lexer = Lexer(Parser.lrules) 117 | 118 | def __dprint(self, *msgs): 119 | if not self.debug: 120 | return 121 | for m in msgs: 122 | print(m) 123 | 124 | def __reset_parser(self): 125 | """Reset parser's internal variables 126 | 127 | Restore the parser to an initial state. Useful when creating a 128 | new parser or reusing an existing one. 129 | """ 130 | self.result = [] 131 | self.hash_comments = [] 132 | 133 | self.__cstate = None 134 | self.__curcommand = None 135 | self.__curstringlist = None 136 | self.__expected = None 137 | self.__expected_brackets = [] 138 | RequireCommand.loaded_extensions = [] 139 | 140 | def __set_expected(self, *args, **kwargs): 141 | """Set the next expected token. 142 | 143 | One or more tokens can be provided. (they will represent the 144 | valid possibilities for the next token). 145 | """ 146 | self.__expected = args 147 | 148 | def __push_expected_bracket(self, ttype: str, tvalue: bytes): 149 | """Append a new expected bracket. 150 | 151 | Next time a bracket is closed, it must match the one provided here. 152 | """ 153 | self.__expected_brackets.append((ttype, tvalue)) 154 | 155 | def __pop_expected_bracket(self, ttype: str, tvalue): 156 | """Drop the last expected bracket. 157 | 158 | If the given bracket doesn't match the dropped expected bracket, 159 | or if no bracket is expected at all, a ParseError will be raised. 160 | """ 161 | try: 162 | etype, evalue = self.__expected_brackets.pop() 163 | except IndexError: 164 | raise ParseError("unexpected closing bracket %s (none opened)" % (tvalue,)) 165 | if ttype != etype: 166 | raise ParseError( 167 | "unexpected closing bracket %s (expected %s)" % (tvalue, evalue) 168 | ) 169 | 170 | def __up(self, onlyrecord: bool = False): 171 | """Return to the current command's parent 172 | 173 | This method should be called each time a command is 174 | complete. In case of a top level command (no parent), it is 175 | recorded into a specific list for further usage. 176 | 177 | :param onlyrecord: tell to only record the new command into its parent. 178 | """ 179 | if self.__curcommand.must_follow is not None: 180 | if not self.__curcommand.parent: 181 | prevcmd = self.result[-1] if len(self.result) != 0 else None 182 | else: 183 | prevcmd = ( 184 | self.__curcommand.parent.children[-2] 185 | if len(self.__curcommand.parent.children) >= 2 186 | else None 187 | ) 188 | if prevcmd is None or prevcmd.name not in self.__curcommand.must_follow: 189 | raise ParseError( 190 | "the %s command must follow an %s command" 191 | % ( 192 | self.__curcommand.name, 193 | " or ".join(self.__curcommand.must_follow), 194 | ) 195 | ) 196 | 197 | if not self.__curcommand.parent: 198 | # collect current amount of hash comments for later 199 | # parsing into names and desciptions 200 | self.__curcommand.hash_comments = self.hash_comments 201 | self.hash_comments = [] 202 | self.result += [self.__curcommand] 203 | 204 | if onlyrecord: 205 | # We are done 206 | return 207 | 208 | while self.__curcommand: 209 | self.__curcommand = self.__curcommand.parent 210 | if not self.__curcommand: 211 | break 212 | # Make sure to detect all done tests (including 'not' ones). 213 | condition = ( 214 | self.__curcommand.get_type() == "test" 215 | and self.__curcommand.iscomplete() 216 | ) 217 | if condition: 218 | continue 219 | # If we are on a control accepting a test list, next token 220 | # must be a comma or a right parenthesis. 221 | condition = ( 222 | self.__curcommand.get_type() == "test" 223 | and self.__curcommand.variable_args_nb 224 | ) 225 | if condition: 226 | self.__set_expected("comma", "right_parenthesis") 227 | break 228 | 229 | def __check_command_completion(self, testsemicolon: bool = True) -> bool: 230 | """Check for command(s) completion 231 | 232 | This function should be called each time a new argument is 233 | seen by the parser in order to check a command is complete. As 234 | not only one command can be ended when receiving a new 235 | argument (nested commands case), we apply the same work to 236 | parent commands. 237 | 238 | :param testsemicolon: if True, indicates that the next 239 | expected token must be a semicolon (for commands that need one) 240 | :return: True if command is 241 | considered as complete, False otherwise. 242 | """ 243 | if not self.__curcommand.iscomplete(): 244 | return True 245 | 246 | ctype = self.__curcommand.get_type() 247 | condition = ctype == "action" or ( 248 | ctype == "control" and not self.__curcommand.accept_children 249 | ) 250 | if condition: 251 | if testsemicolon: 252 | self.__set_expected("semicolon") 253 | return True 254 | 255 | while self.__curcommand.parent: 256 | cmd = self.__curcommand 257 | self.__curcommand = self.__curcommand.parent 258 | if self.__curcommand.get_type() in ["control", "test"]: 259 | if self.__curcommand.iscomplete(): 260 | if self.__curcommand.get_type() == "control": 261 | self.__set_expected("left_cbracket") 262 | break 263 | continue 264 | if not self.__curcommand.check_next_arg("test", cmd, add=False): 265 | return False 266 | if not self.__curcommand.iscomplete(): 267 | if self.__curcommand.variable_args_nb: 268 | self.__set_expected("comma", "right_parenthesis") 269 | break 270 | return True 271 | 272 | def __stringlist(self, ttype: str, tvalue: bytes) -> bool: 273 | """Specific method to parse the 'string-list' type 274 | 275 | Syntax: 276 | string-list = "[" string *("," string) "]" / string 277 | ; if there is only a single string, the brackets 278 | ; are optional 279 | """ 280 | if ttype == "string": 281 | self.__curstringlist += [tvalue.decode("utf-8")] 282 | self.__set_expected("comma", "right_bracket") 283 | return True 284 | if ttype == "comma": 285 | self.__set_expected("string") 286 | return True 287 | if ttype == "right_bracket": 288 | self.__pop_expected_bracket(ttype, tvalue) 289 | self.__curcommand.check_next_arg("stringlist", self.__curstringlist) 290 | self.__cstate = self.__arguments 291 | return self.__check_command_completion() 292 | return False 293 | 294 | def __argument(self, ttype: str, tvalue: bytes) -> bool: 295 | """Argument parsing method 296 | 297 | This method acts as an entry point for 'argument' parsing. 298 | 299 | Syntax: 300 | string-list / number / tag 301 | 302 | :param ttype: current token type 303 | :param tvalue: current token value 304 | :return: False if an error is encountered, True otherwise 305 | """ 306 | if ttype in ["multiline", "string"]: 307 | return self.__curcommand.check_next_arg("string", tvalue.decode("utf-8")) 308 | 309 | if ttype in ["number", "tag"]: 310 | return self.__curcommand.check_next_arg(ttype, tvalue.decode("ascii")) 311 | 312 | if ttype == "left_bracket": 313 | self.__push_expected_bracket("right_bracket", b"}") 314 | self.__cstate = self.__stringlist 315 | self.__curstringlist = [] 316 | self.__set_expected("string") 317 | return True 318 | 319 | condition = ( 320 | ttype in ["left_cbracket", "comma"] 321 | and self.__curcommand.non_deterministic_args 322 | ) 323 | if condition: 324 | self.__curcommand.reassign_arguments() 325 | # rewind lexer 326 | self.lexer.pos -= 1 327 | return True 328 | 329 | return False 330 | 331 | def __arguments(self, ttype: str, tvalue: bytes) -> bool: 332 | """Arguments parsing method 333 | 334 | Entry point for command arguments parsing. The parser must 335 | call this method for each parsed command (either a control, 336 | action or test). 337 | 338 | Syntax: 339 | *argument [ test / test-list ] 340 | 341 | :param ttype: current token type 342 | :param tvalue: current token value 343 | :return: False if an error is encountered, True otherwise 344 | """ 345 | if ttype == "identifier": 346 | test = get_command_instance(tvalue.decode("ascii"), self.__curcommand) 347 | if test.get_type() != "test": 348 | raise ParseError( 349 | "Expected test command, '{}' found instead".format(test.name) 350 | ) 351 | self.__curcommand.check_next_arg("test", test) 352 | self.__expected = test.get_expected_first() 353 | self.__curcommand = test 354 | return self.__check_command_completion(testsemicolon=False) 355 | 356 | if ttype == "left_parenthesis": 357 | self.__push_expected_bracket("right_parenthesis", b")") 358 | self.__set_expected("identifier") 359 | return True 360 | 361 | if ttype == "comma": 362 | self.__set_expected("identifier") 363 | return True 364 | 365 | if ttype == "right_parenthesis": 366 | self.__pop_expected_bracket(ttype, tvalue) 367 | self.__up() 368 | return True 369 | 370 | if self.__argument(ttype, tvalue): 371 | return self.__check_command_completion(testsemicolon=False) 372 | 373 | return False 374 | 375 | def __command(self, ttype: str, tvalue: bytes) -> bool: 376 | """Command parsing method 377 | 378 | Entry point for command parsing. Here is expected behaviour: 379 | * Handle command beginning if detected, 380 | * Call the appropriate sub-method (specified by __cstate) to 381 | handle the body, 382 | * Handle command ending or block opening if detected. 383 | 384 | Syntax: 385 | identifier arguments (";" / block) 386 | 387 | :param ttype: current token type 388 | :param tvalue: current token value 389 | :return: False if an error is encountered, True otherwise 390 | """ 391 | if self.__cstate is None: 392 | if ttype == "right_cbracket": 393 | self.__pop_expected_bracket(ttype, tvalue) 394 | self.__up() 395 | self.__cstate = None 396 | return True 397 | 398 | if ttype != "identifier": 399 | return False 400 | command = get_command_instance(tvalue.decode("ascii"), self.__curcommand) 401 | if command.get_type() == "test": 402 | raise ParseError("%s may not appear as a first command" % command.name) 403 | if ( 404 | command.get_type() == "control" 405 | and command.accept_children 406 | and command.has_arguments() 407 | ): 408 | self.__set_expected("identifier") 409 | if self.__curcommand is not None: 410 | if not self.__curcommand.addchild(command): 411 | raise ParseError( 412 | "%s unexpected after a %s" % (tvalue, self.__curcommand.name) 413 | ) 414 | self.__curcommand = command 415 | self.__cstate = self.__arguments 416 | 417 | return True 418 | 419 | if self.__cstate(ttype, tvalue): 420 | return True 421 | 422 | if ttype == "left_cbracket": 423 | self.__push_expected_bracket("right_cbracket", b"}") 424 | self.__cstate = None 425 | return True 426 | 427 | if ttype == "semicolon": 428 | self.__cstate = None 429 | if not self.__check_command_completion(testsemicolon=False): 430 | return False 431 | self.__curcommand.complete_cb() 432 | self.__up() 433 | return True 434 | return False 435 | 436 | def parse(self, text: bytes) -> bool: 437 | """The parser entry point. 438 | 439 | Parse the provided text to check for its validity. 440 | 441 | On success, the parsing tree is available into the result 442 | attribute. It is a list of sievecommands.Command objects (see 443 | the module documentation for specific information). 444 | 445 | On error, an string containing the explicit reason is 446 | available into the error attribute. 447 | 448 | :param text: a string containing the data to parse 449 | :return: True on success (no error detected), False otherwise 450 | """ 451 | if isinstance(text, str): 452 | text = text.encode("utf-8") 453 | 454 | self.__reset_parser() 455 | try: 456 | ttype: str 457 | tvalue: bytes = b"" 458 | for ttype, tvalue in self.lexer.scan(text): 459 | if ttype == "hash_comment": 460 | self.hash_comments += [tvalue.strip()] 461 | continue 462 | if ttype == "bracket_comment": 463 | continue 464 | if self.__expected is not None: 465 | if ttype not in self.__expected: 466 | if self.lexer.pos < len(text) + len(tvalue): 467 | msg = "{} found while {} expected near '{}'".format( 468 | ttype, 469 | "|".join(self.__expected), 470 | text.decode()[self.lexer.pos], 471 | ) 472 | else: 473 | msg = "%s found while %s expected at end of file" % ( 474 | ttype, 475 | "|".join(self.__expected), 476 | ) 477 | raise ParseError(msg) 478 | self.__expected = None 479 | 480 | if not self.__command(ttype, tvalue): 481 | msg = "unexpected token '%s' found near '%s'" % ( 482 | tvalue.decode(), 483 | text.decode()[self.lexer.pos], 484 | ) 485 | raise ParseError(msg) 486 | if self.__expected_brackets: 487 | self.__set_expected(self.__expected_brackets[-1][0]) 488 | if self.__expected is not None: 489 | raise ParseError( 490 | "end of script reached while %s expected" 491 | % "|".join(self.__expected) 492 | ) 493 | 494 | except (ParseError, CommandError) as e: 495 | self.error_pos = ( 496 | self.lexer.curlineno(), 497 | self.lexer.curcolno(), 498 | len(tvalue), 499 | ) 500 | self.error = "line %d: %s" % (self.error_pos[0], str(e)) 501 | return False 502 | return True 503 | 504 | def parse_file(self, name: str) -> bool: 505 | """Parse the content of a file. 506 | 507 | See 'parse' method for information. 508 | 509 | :param name: the pathname of the file to parse 510 | :return: True on success (no error detected), False otherwise 511 | """ 512 | with open(name, "rb") as fp: 513 | return self.parse(fp.read()) 514 | 515 | def dump(self, target=sys.stdout): 516 | """Dump the parsing tree. 517 | 518 | This method displays the parsing tree on the standard output. 519 | """ 520 | for r in self.result: 521 | r.dump(target=target) 522 | 523 | 524 | if __name__ == "__main__": 525 | import argparse 526 | 527 | parser = argparse.ArgumentParser() 528 | parser.add_argument( 529 | "-v", 530 | "--verbose", 531 | action="store_true", 532 | default=False, 533 | help="Activate verbose mode", 534 | ) 535 | parser.add_argument( 536 | "-d", 537 | "--debug", 538 | action="store_true", 539 | default=False, 540 | help="Activate debug traces", 541 | ) 542 | parser.add_argument( 543 | "--tosieve", action="store_true", help="Print parser results using sieve" 544 | ) 545 | parser.add_argument("files", type=str, nargs="+", help="Files to parse") 546 | args = parser.parse_args() 547 | for fname in args.files: 548 | p = Parser(debug=args.debug) 549 | print(f"Parsing file {fname}... ", end=" ") 550 | if p.parse_file(fname): 551 | print("OK") 552 | if args.verbose: 553 | p.dump() 554 | if args.tosieve: 555 | for r in p.result: 556 | r.tosieve() 557 | continue 558 | print("ERROR") 559 | print(p.error) 560 | -------------------------------------------------------------------------------- /sievelib/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonioo/sievelib/f73c59a19c57ae45b8056b766a90049108b27dc0/sievelib/tests/__init__.py -------------------------------------------------------------------------------- /sievelib/tests/files/utf8_sieve.txt: -------------------------------------------------------------------------------- 1 | require ["fileinto", "reject"]; 2 | 3 | # Filter: UTF8 Test Filter äöüß 汉语/漢語 Hànyǔ 4 | if allof (header :contains ["Subject"] ["€ 300"]) { 5 | fileinto "Spam"; 6 | stop; 7 | } 8 | -------------------------------------------------------------------------------- /sievelib/tests/test_factory.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import io 3 | 4 | from sievelib.factory import FilterAlreadyExists, FiltersSet 5 | from .. import parser 6 | 7 | 8 | class FactoryTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.fs = FiltersSet("test") 12 | 13 | def test_add_duplicate_filter(self): 14 | """Try to add the same filter name twice, should fail.""" 15 | self.fs.addfilter( 16 | "ruleX", 17 | [ 18 | ("Sender", ":is", "toto@toto.com"), 19 | ], 20 | [ 21 | ("fileinto", ":copy", "Toto"), 22 | ], 23 | ) 24 | with self.assertRaises(FilterAlreadyExists): 25 | self.fs.addfilter( 26 | "ruleX", 27 | [ 28 | ("Sender", ":is", "toto@toto.com"), 29 | ], 30 | [ 31 | ("fileinto", ":copy", "Toto"), 32 | ], 33 | ) 34 | 35 | def test_updatefilter(self): 36 | self.fs.addfilter( 37 | "ruleX", 38 | [ 39 | ("Sender", ":is", "toto@toto.com"), 40 | ], 41 | [ 42 | ("fileinto", ":copy", "Toto"), 43 | ], 44 | ) 45 | result = self.fs.updatefilter( 46 | "ruleY", 47 | "ruleX", 48 | [ 49 | ("Sender", ":is", "tata@toto.com"), 50 | ], 51 | [ 52 | ("fileinto", ":copy", "Tata"), 53 | ], 54 | ) 55 | self.assertFalse(result) 56 | result = self.fs.updatefilter( 57 | "ruleX", 58 | "ruleY", 59 | [ 60 | ("Sender", ":is", "tata@toto.com"), 61 | ], 62 | [ 63 | ("fileinto", ":copy", "Tata"), 64 | ], 65 | ) 66 | self.assertTrue(result) 67 | self.assertIs(self.fs.getfilter("ruleX"), None) 68 | self.assertIsNot(self.fs.getfilter("ruleY"), None) 69 | 70 | def test_updatefilter_duplicate(self): 71 | self.fs.addfilter( 72 | "ruleX", 73 | [ 74 | ("Sender", ":is", "toto@toto.com"), 75 | ], 76 | [ 77 | ("fileinto", ":copy", "Toto"), 78 | ], 79 | ) 80 | self.fs.addfilter( 81 | "ruleY", 82 | [ 83 | ("Sender", ":is", "toto@tota.com"), 84 | ], 85 | [ 86 | ("fileinto", ":copy", "Tota"), 87 | ], 88 | ) 89 | with self.assertRaises(FilterAlreadyExists): 90 | self.fs.updatefilter( 91 | "ruleX", 92 | "ruleY", 93 | [ 94 | ("Sender", ":is", "toto@toti.com"), 95 | ], 96 | [ 97 | ("fileinto", ":copy", "Toti"), 98 | ], 99 | ) 100 | 101 | def test_replacefilter(self): 102 | self.fs.addfilter( 103 | "ruleX", 104 | [ 105 | ("Sender", ":is", "toto@toto.com"), 106 | ], 107 | [ 108 | ("fileinto", ":copy", "Toto"), 109 | ], 110 | ) 111 | self.fs.addfilter( 112 | "ruleY", 113 | [ 114 | ("Sender", ":is", "toto@tota.com"), 115 | ], 116 | [ 117 | ("fileinto", ":copy", "Tota"), 118 | ], 119 | ) 120 | content = self.fs.getfilter("ruleX") 121 | result = self.fs.replacefilter("ruleZ", content) 122 | self.assertFalse(result) 123 | result = self.fs.replacefilter("ruleY", content) 124 | self.assertTrue(result) 125 | 126 | def test_get_filter_conditions(self): 127 | """Test get_filter_conditions method.""" 128 | orig_conditions = [("Sender", ":is", "toto@toto.com")] 129 | self.fs.addfilter( 130 | "ruleX", 131 | orig_conditions, 132 | [ 133 | ("fileinto", ":copy", "Toto"), 134 | ], 135 | ) 136 | conditions = self.fs.get_filter_conditions("ruleX") 137 | self.assertEqual(orig_conditions, conditions) 138 | 139 | orig_conditions = [ 140 | ("exists", "list-help", "list-unsubscribe", "list-subscribe", "list-owner") 141 | ] 142 | self.fs.addfilter("ruleY", orig_conditions, [("fileinto", "List")]) 143 | conditions = self.fs.get_filter_conditions("ruleY") 144 | self.assertEqual(orig_conditions, conditions) 145 | 146 | orig_conditions = [("Sender", ":notis", "toto@toto.com")] 147 | self.fs.addfilter( 148 | "ruleZ", 149 | orig_conditions, 150 | [ 151 | ("fileinto", ":copy", "Toto"), 152 | ], 153 | ) 154 | conditions = self.fs.get_filter_conditions("ruleZ") 155 | self.assertEqual(orig_conditions, conditions) 156 | 157 | orig_conditions = [ 158 | ( 159 | "notexists", 160 | "list-help", 161 | "list-unsubscribe", 162 | "list-subscribe", 163 | "list-owner", 164 | ) 165 | ] 166 | self.fs.addfilter("ruleA", orig_conditions, [("fileinto", "List")]) 167 | conditions = self.fs.get_filter_conditions("ruleA") 168 | self.assertEqual(orig_conditions, conditions) 169 | 170 | orig_conditions = [("envelope", ":is", ["From"], ["hello"])] 171 | self.fs.addfilter("ruleB", orig_conditions, [("fileinto", "INBOX")]) 172 | conditions = self.fs.get_filter_conditions("ruleB") 173 | self.assertEqual(orig_conditions, conditions) 174 | 175 | orig_conditions = [("body", ":raw", ":notcontains", "matteo")] 176 | self.fs.addfilter("ruleC", orig_conditions, [("fileinto", "INBOX")]) 177 | conditions = self.fs.get_filter_conditions("ruleC") 178 | self.assertEqual(orig_conditions, conditions) 179 | 180 | orig_conditions = [ 181 | ("currentdate", ":zone", "+0100", ":notis", "date", "2019-02-26") 182 | ] 183 | self.fs.addfilter("ruleD", orig_conditions, [("fileinto", "INBOX")]) 184 | conditions = self.fs.get_filter_conditions("ruleD") 185 | self.assertEqual(orig_conditions, conditions) 186 | 187 | orig_conditions = [ 188 | ("currentdate", ":zone", "+0100", ":value", "gt", "date", "2019-02-26") 189 | ] 190 | self.fs.addfilter("ruleE", orig_conditions, [("fileinto", "INBOX")]) 191 | conditions = self.fs.get_filter_conditions("ruleE") 192 | self.assertEqual(orig_conditions, conditions) 193 | 194 | def test_get_filter_conditions_from_parser_result(self): 195 | res = """require ["fileinto"]; 196 | 197 | # rule:[test] 198 | if anyof (exists ["Subject"]) { 199 | fileinto "INBOX"; 200 | } 201 | """ 202 | p = parser.Parser() 203 | p.parse(res) 204 | fs = FiltersSet("test", "# rule:") 205 | fs.from_parser_result(p) 206 | c = fs.get_filter_conditions("[test]") 207 | self.assertEqual(c, [("exists", "Subject")]) 208 | 209 | res = """require ["date", "fileinto"]; 210 | 211 | # rule:aaa 212 | if anyof (currentdate :zone "+0100" :is "date" ["2019-03-27"]) { 213 | fileinto "INBOX"; 214 | } 215 | """ 216 | p = parser.Parser() 217 | p.parse(res) 218 | fs = FiltersSet("aaa", "# rule:") 219 | fs.from_parser_result(p) 220 | c = fs.get_filter_conditions("aaa") 221 | self.assertEqual( 222 | c, [("currentdate", ":zone", "+0100", ":is", "date", "2019-03-27")] 223 | ) 224 | 225 | res = """require ["envelope", "fileinto"]; 226 | 227 | # rule:[aaa] 228 | if anyof (envelope :contains ["To"] ["hello@world.it"]) { 229 | fileinto "INBOX"; 230 | } 231 | """ 232 | p = parser.Parser() 233 | p.parse(res) 234 | fs = FiltersSet("aaa", "# rule:") 235 | fs.from_parser_result(p) 236 | c = fs.get_filter_conditions("[aaa]") 237 | self.assertEqual(c, [("envelope", ":contains", ["To"], ["hello@world.it"])]) 238 | 239 | def test_get_filter_matchtype(self): 240 | """Test get_filter_matchtype method.""" 241 | self.fs.addfilter( 242 | "ruleX", 243 | [ 244 | ("Sender", ":is", "toto@toto.com"), 245 | ], 246 | [ 247 | ("fileinto", ":copy", "Toto"), 248 | ], 249 | ) 250 | match_type = self.fs.get_filter_matchtype("ruleX") 251 | self.assertEqual(match_type, "anyof") 252 | 253 | def test_get_filter_actions(self): 254 | """Test get_filter_actions method.""" 255 | self.fs.addfilter( 256 | "ruleX", 257 | [ 258 | ("Sender", ":is", "toto@toto.com"), 259 | ], 260 | [ 261 | ("fileinto", ":copy", "Toto"), 262 | ], 263 | ) 264 | actions = self.fs.get_filter_actions("ruleX") 265 | self.assertIn("fileinto", actions[0]) 266 | self.assertIn(":copy", actions[0]) 267 | self.assertIn("Toto", actions[0]) 268 | 269 | self.fs.addfilter("ruleY", [("Subject", ":contains", "aaa")], [("stop",)]) 270 | actions = self.fs.get_filter_actions("ruleY") 271 | self.assertIn("stop", actions[0]) 272 | 273 | def test_add_header_filter(self): 274 | output = io.StringIO() 275 | self.fs.addfilter( 276 | "rule1", 277 | [ 278 | ("Sender", ":is", "toto@toto.com"), 279 | ], 280 | [ 281 | ("fileinto", ":copy", "Toto"), 282 | ], 283 | ) 284 | self.assertIsNot(self.fs.getfilter("rule1"), None) 285 | self.fs.tosieve(output) 286 | self.assertEqual( 287 | output.getvalue(), 288 | """require ["fileinto", "copy"]; 289 | 290 | # Filter: rule1 291 | if anyof (header :is "Sender" "toto@toto.com") { 292 | fileinto :copy "Toto"; 293 | } 294 | """, 295 | ) 296 | output.close() 297 | 298 | def test_use_action_with_tag(self): 299 | output = io.StringIO() 300 | self.fs.addfilter( 301 | "rule1", 302 | [ 303 | ("Sender", ":is", "toto@toto.com"), 304 | ], 305 | [ 306 | ("redirect", ":copy", "toto@titi.com"), 307 | ], 308 | ) 309 | self.assertIsNot(self.fs.getfilter("rule1"), None) 310 | self.fs.tosieve(output) 311 | self.assertEqual( 312 | output.getvalue(), 313 | """require ["copy"]; 314 | 315 | # Filter: rule1 316 | if anyof (header :is "Sender" "toto@toto.com") { 317 | redirect :copy "toto@titi.com"; 318 | } 319 | """, 320 | ) 321 | output.close() 322 | 323 | def test_add_header_filter_with_not(self): 324 | output = io.StringIO() 325 | self.fs.addfilter( 326 | "rule1", 327 | [("Sender", ":notcontains", "toto@toto.com")], 328 | [("fileinto", "Toto")], 329 | ) 330 | self.assertIsNot(self.fs.getfilter("rule1"), None) 331 | self.fs.tosieve(output) 332 | self.assertEqual( 333 | output.getvalue(), 334 | """require ["fileinto"]; 335 | 336 | # Filter: rule1 337 | if anyof (not header :contains "Sender" "toto@toto.com") { 338 | fileinto "Toto"; 339 | } 340 | """, 341 | ) 342 | 343 | def test_add_exists_filter(self): 344 | output = io.StringIO() 345 | self.fs.addfilter( 346 | "rule1", 347 | [ 348 | ( 349 | "exists", 350 | "list-help", 351 | "list-unsubscribe", 352 | "list-subscribe", 353 | "list-owner", 354 | ) 355 | ], 356 | [("fileinto", "Toto")], 357 | ) 358 | self.assertIsNot(self.fs.getfilter("rule1"), None) 359 | self.fs.tosieve(output) 360 | self.assertEqual( 361 | output.getvalue(), 362 | """require ["fileinto"]; 363 | 364 | # Filter: rule1 365 | if anyof (exists ["list-help","list-unsubscribe","list-subscribe","list-owner"]) { 366 | fileinto "Toto"; 367 | } 368 | """, 369 | ) 370 | 371 | def test_add_exists_filter_with_not(self): 372 | output = io.StringIO() 373 | self.fs.addfilter( 374 | "rule1", 375 | [ 376 | ( 377 | "notexists", 378 | "list-help", 379 | "list-unsubscribe", 380 | "list-subscribe", 381 | "list-owner", 382 | ) 383 | ], 384 | [("fileinto", "Toto")], 385 | ) 386 | self.assertIsNot(self.fs.getfilter("rule1"), None) 387 | self.fs.tosieve(output) 388 | self.assertEqual( 389 | output.getvalue(), 390 | """require ["fileinto"]; 391 | 392 | # Filter: rule1 393 | if anyof (not exists ["list-help","list-unsubscribe","list-subscribe","list-owner"]) { 394 | fileinto "Toto"; 395 | } 396 | """, 397 | ) 398 | 399 | def test_add_size_filter(self): 400 | output = io.StringIO() 401 | self.fs.addfilter( 402 | "rule1", [("size", ":over", "100k")], [("fileinto", "Totoéé")] 403 | ) 404 | self.assertIsNot(self.fs.getfilter("rule1"), None) 405 | self.fs.tosieve(output) 406 | self.assertEqual( 407 | output.getvalue(), 408 | """require ["fileinto"]; 409 | 410 | # Filter: rule1 411 | if anyof (size :over 100k) { 412 | fileinto "Totoéé"; 413 | } 414 | """, 415 | ) 416 | 417 | def test_remove_filter(self): 418 | self.fs.addfilter( 419 | "rule1", [("Sender", ":is", "toto@toto.com")], [("fileinto", "Toto")] 420 | ) 421 | self.assertIsNot(self.fs.getfilter("rule1"), None) 422 | self.assertEqual(self.fs.removefilter("rule1"), True) 423 | self.assertIs(self.fs.getfilter("rule1"), None) 424 | 425 | def test_disablefilter(self): 426 | """ 427 | FIXME: Extra spaces are written between if and anyof, why?! 428 | """ 429 | self.fs.addfilter( 430 | "rule1", [("Sender", ":is", "toto@toto.com")], [("fileinto", "Toto")] 431 | ) 432 | self.assertIsNot(self.fs.getfilter("rule1"), None) 433 | self.assertEqual(self.fs.disablefilter("rule1"), True) 434 | output = io.StringIO() 435 | self.fs.tosieve(output) 436 | self.assertEqual( 437 | output.getvalue(), 438 | """require ["fileinto"]; 439 | 440 | # Filter: rule1 441 | if false { 442 | if anyof (header :is "Sender" "toto@toto.com") { 443 | fileinto "Toto"; 444 | } 445 | } 446 | """, 447 | ) 448 | output.close() 449 | self.assertEqual(self.fs.is_filter_disabled("rule1"), True) 450 | 451 | def test_add_filter_unicode(self): 452 | """Add a filter containing unicode data.""" 453 | name = "Test\xe9".encode("utf-8") 454 | self.fs.addfilter( 455 | name, 456 | [ 457 | ("Sender", ":is", "toto@toto.com"), 458 | ], 459 | [ 460 | ("fileinto", "Toto"), 461 | ], 462 | ) 463 | self.assertIsNot(self.fs.getfilter("Testé"), None) 464 | self.assertEqual( 465 | "{}".format(self.fs), 466 | """require ["fileinto"]; 467 | 468 | # Filter: Testé 469 | if anyof (header :is "Sender" "toto@toto.com") { 470 | fileinto "Toto"; 471 | } 472 | """, 473 | ) 474 | 475 | def test_add_body_filter(self): 476 | """Add a body filter.""" 477 | self.fs.addfilter( 478 | "test", [("body", ":raw", ":contains", "matteo")], [("fileinto", "Toto")] 479 | ) 480 | self.assertEqual( 481 | "{}".format(self.fs), 482 | """require ["body", "fileinto"]; 483 | 484 | # Filter: test 485 | if anyof (body :contains :raw ["matteo"]) { 486 | fileinto "Toto"; 487 | } 488 | """, 489 | ) 490 | 491 | def test_add_notbody_filter(self): 492 | """Add a not body filter.""" 493 | self.fs.addfilter( 494 | "test", [("body", ":raw", ":notcontains", "matteo")], [("fileinto", "Toto")] 495 | ) 496 | self.assertEqual( 497 | "{}".format(self.fs), 498 | """require ["body", "fileinto"]; 499 | 500 | # Filter: test 501 | if anyof (not body :contains :raw ["matteo"]) { 502 | fileinto "Toto"; 503 | } 504 | """, 505 | ) 506 | 507 | def test_add_envelope_filter(self): 508 | """Add a envelope filter.""" 509 | self.fs.addfilter( 510 | "test", [("envelope", ":is", ["From"], ["hello"])], [("fileinto", "INBOX")] 511 | ) 512 | self.assertEqual( 513 | "{}".format(self.fs), 514 | """require ["envelope", "fileinto"]; 515 | 516 | # Filter: test 517 | if anyof (envelope :is ["From"] ["hello"]) { 518 | fileinto "INBOX"; 519 | } 520 | """, 521 | ) 522 | 523 | def test_add_notenvelope_filter(self): 524 | """Add a not envelope filter.""" 525 | self.fs.addfilter( 526 | "test", 527 | [("envelope", ":notis", ["From"], ["hello"])], 528 | [("fileinto", "INBOX")], 529 | ) 530 | self.assertEqual( 531 | "{}".format(self.fs), 532 | """require ["envelope", "fileinto"]; 533 | 534 | # Filter: test 535 | if anyof (not envelope :is ["From"] ["hello"]) { 536 | fileinto "INBOX"; 537 | } 538 | """, 539 | ) 540 | 541 | def test_add_currentdate_filter(self): 542 | """Add a currentdate filter.""" 543 | self.fs.addfilter( 544 | "test", 545 | [("currentdate", ":zone", "+0100", ":is", "date", "2019-02-26")], 546 | [("fileinto", "INBOX")], 547 | ) 548 | self.assertEqual( 549 | "{}".format(self.fs), 550 | """require ["date", "fileinto"]; 551 | 552 | # Filter: test 553 | if anyof (currentdate :zone "+0100" :is "date" ["2019-02-26"]) { 554 | fileinto "INBOX"; 555 | } 556 | """, 557 | ) 558 | 559 | self.fs.removefilter("test") 560 | self.fs.addfilter( 561 | "test", 562 | [("currentdate", ":zone", "+0100", ":value", "gt", "date", "2019-02-26")], 563 | [("fileinto", "INBOX")], 564 | ) 565 | self.assertEqual( 566 | "{}".format(self.fs), 567 | """require ["date", "fileinto", "relational"]; 568 | 569 | # Filter: test 570 | if anyof (currentdate :zone "+0100" :value "gt" "date" ["2019-02-26"]) { 571 | fileinto "INBOX"; 572 | } 573 | """, 574 | ) 575 | 576 | def test_vacation(self): 577 | self.fs.addfilter( 578 | "test", 579 | [("Subject", ":matches", "*")], 580 | [ 581 | ( 582 | "vacation", 583 | ":subject", 584 | "Example Autoresponder Subject", 585 | ":days", 586 | 7, 587 | ":mime", 588 | "Example Autoresponder Body", 589 | ) 590 | ], 591 | ) 592 | output = io.StringIO() 593 | self.fs.tosieve(output) 594 | self.assertEqual( 595 | output.getvalue(), 596 | """require ["vacation"]; 597 | 598 | # Filter: test 599 | if anyof (header :matches "Subject" "*") { 600 | vacation :subject "Example Autoresponder Subject" :days 7 :mime "Example Autoresponder Body"; 601 | } 602 | """, 603 | ) 604 | 605 | def test_dump(self): 606 | self.fs.addfilter( 607 | "test", 608 | [("Subject", ":matches", "*")], 609 | [ 610 | ( 611 | "vacation", 612 | ":subject", 613 | "Example Autoresponder Subject", 614 | ":days", 615 | 7, 616 | ":mime", 617 | "Example Autoresponder Body", 618 | ) 619 | ], 620 | ) 621 | output = io.StringIO() 622 | self.fs.dump(output) 623 | self.assertEqual( 624 | output.getvalue(), 625 | """require (type: control) 626 | [vacation] 627 | 628 | Filter Name: test 629 | if (type: control) 630 | anyof (type: test) 631 | header (type: test) 632 | :matches 633 | "Subject" 634 | "*" 635 | vacation (type: action) 636 | :subject 637 | "Example Autoresponder Subject" 638 | :days 639 | 7 640 | :mime 641 | "Example Autoresponder Body" 642 | """, 643 | ) 644 | 645 | def test_stringlist_condition(self): 646 | self.fs.addfilter( 647 | "test", 648 | [(["X-Foo", "X-Bar"], ":contains", ["bar", "baz"])], 649 | [], 650 | ) 651 | output = io.StringIO() 652 | self.fs.tosieve(output) 653 | self.assertEqual( 654 | output.getvalue(), 655 | """# Filter: test 656 | if anyof (header :contains ["X-Foo", "X-Bar"] ["bar", "baz"]) { 657 | } 658 | """, 659 | ) 660 | 661 | def test_address_string_args(self): 662 | self.fs.addfilter( 663 | "test", 664 | [("address", ":is", "from", "user1@test.com")], 665 | [("fileinto", "folder")], 666 | ) 667 | output = io.StringIO() 668 | self.fs.tosieve(output) 669 | self.assertEqual( 670 | output.getvalue(), 671 | """require ["fileinto"]; 672 | 673 | # Filter: test 674 | if anyof (address :is "from" "user1@test.com") { 675 | fileinto "folder"; 676 | } 677 | """, 678 | ) 679 | 680 | def test_address_list_args(self): 681 | self.fs.addfilter( 682 | "test", 683 | [ 684 | ( 685 | "address", 686 | ":is", 687 | ["from", "reply-to"], 688 | ["user1@test.com", "user2@test.com"], 689 | ) 690 | ], 691 | [("fileinto", ":create", "folder")], 692 | ) 693 | output = io.StringIO() 694 | self.fs.tosieve(output) 695 | self.assertEqual( 696 | output.getvalue(), 697 | """require ["fileinto", "mailbox"]; 698 | 699 | # Filter: test 700 | if anyof (address :is ["from","reply-to"] ["user1@test.com","user2@test.com"]) { 701 | fileinto :create "folder"; 702 | } 703 | """, 704 | ) 705 | 706 | def test_notify_action(self): 707 | self.fs.addfilter( 708 | "test", 709 | [ 710 | ( 711 | "from", 712 | ":contains", 713 | "boss@example.org", 714 | ) 715 | ], 716 | [("notify", ":importance", "1", ":message", "This is probably very important", "mailto:alm@example.com")], 717 | ) 718 | 719 | output = io.StringIO() 720 | self.fs.tosieve(output) 721 | self.assertEqual( 722 | output.getvalue(), 723 | """require ["enotify"]; 724 | 725 | # Filter: test 726 | if anyof (header :contains "from" "boss@example.org") { 727 | notify :importance "1" :message "This is probably very important" "mailto:alm@example.com"; 728 | } 729 | """ 730 | ) 731 | 732 | if __name__ == "__main__": 733 | unittest.main() 734 | -------------------------------------------------------------------------------- /sievelib/tests/test_managesieve.py: -------------------------------------------------------------------------------- 1 | """Managesieve test cases.""" 2 | 3 | import unittest 4 | from unittest import mock 5 | 6 | from sievelib import managesieve 7 | 8 | CAPABILITIES = ( 9 | b'"IMPLEMENTATION" "Example1 ManageSieved v001"\r\n' 10 | b'"VERSION" "1.0"\r\n' 11 | b'"SASL" "PLAIN SCRAM-SHA-1 GSSAPI OAUTHBEARER"\r\n' 12 | b'"SIEVE" "fileinto vacation"\r\n' 13 | b'"STARTTLS"\r\n' 14 | ) 15 | 16 | CAPABILITIES_WITHOUT_VERSION = ( 17 | b'"IMPLEMENTATION" "Example1 ManageSieved v001"\r\n' 18 | b'"SASL" "PLAIN SCRAM-SHA-1 GSSAPI OAUTHBEARER"\r\n' 19 | b'"SIEVE" "fileinto vacation"\r\n' 20 | b'"STARTTLS"\r\n' 21 | ) 22 | 23 | AUTHENTICATION = CAPABILITIES + b'OK "Dovecot ready."\r\n' b'OK "Logged in."\r\n' 24 | 25 | LISTSCRIPTS = ( 26 | b'"summer_script"\r\n' 27 | b'"vac\xc3\xa0tion_script"\r\n' 28 | b"{13}\r\n" 29 | b'clever"script\r\n' 30 | b'"main_script" ACTIVE\r\n' 31 | b'OK "Listscripts completed."\r\n' 32 | ) 33 | 34 | GETSCRIPT = ( 35 | b"{54}\r\n" 36 | b"#this is my wonderful script\r\n" 37 | b'reject "I reject all";\r\n' 38 | b'OK "Getscript completed."\r\n' 39 | ) 40 | 41 | 42 | @mock.patch("socket.socket") 43 | class ManageSieveTestCase(unittest.TestCase): 44 | """Managesieve test cases.""" 45 | 46 | def setUp(self): 47 | """Create client.""" 48 | self.client = managesieve.Client("127.0.0.1") 49 | 50 | def authenticate(self, mock_socket): 51 | """Authenticate client.""" 52 | mock_socket.return_value.recv.side_effect = (AUTHENTICATION,) 53 | self.client.connect("user", "password") 54 | 55 | def test_connection(self, mock_socket): 56 | """Test connection.""" 57 | self.authenticate(mock_socket) 58 | self.assertEqual(self.client.get_sieve_capabilities(), ["fileinto", "vacation"]) 59 | mock_socket.return_value.recv.side_effect = (b"OK test\r\n",) 60 | self.client.logout() 61 | 62 | def test_auth_oauthbearer(self, mock_socket): 63 | """Test OAUTHBEARER mechanism.""" 64 | mock_socket.return_value.recv.side_effect = (AUTHENTICATION,) 65 | self.assertTrue(self.client.connect("user", "token", authmech="OAUTHBEARER")) 66 | 67 | def test_capabilities(self, mock_socket): 68 | """Test capabilities command.""" 69 | self.authenticate(mock_socket) 70 | mock_socket.return_value.recv.side_effect = ( 71 | CAPABILITIES + b'OK "Capability completed."\r\n', 72 | ) 73 | capabilities = self.client.capability() 74 | self.assertEqual(capabilities, CAPABILITIES) 75 | 76 | def test_listscripts(self, mock_socket): 77 | """Test listscripts command.""" 78 | self.authenticate(mock_socket) 79 | mock_socket.return_value.recv.side_effect = (LISTSCRIPTS,) 80 | active_script, others = self.client.listscripts() 81 | self.assertEqual(active_script, "main_script") 82 | self.assertEqual(others, ["summer_script", "vacàtion_script", 'clever"script']) 83 | 84 | def test_getscript(self, mock_socket): 85 | """Test getscript command.""" 86 | self.authenticate(mock_socket) 87 | mock_socket.return_value.recv.side_effect = (GETSCRIPT,) 88 | content = self.client.getscript("main_script") 89 | self.assertEqual( 90 | content, '#this is my wonderful script\nreject "I reject all";' 91 | ) 92 | 93 | def test_putscript(self, mock_socket): 94 | """Test putscript command.""" 95 | self.authenticate(mock_socket) 96 | script = """require ["fileinto"]; 97 | 98 | if envelope :contains "to" "tmartin+sent" { 99 | fileinto "INBOX.sent"; 100 | } 101 | """ 102 | mock_socket.return_value.recv.side_effect = (b'OK "putscript completed."\r\n',) 103 | self.assertTrue(self.client.putscript("test_script", script)) 104 | 105 | def test_deletescript(self, mock_socket): 106 | """Test deletescript command.""" 107 | self.authenticate(mock_socket) 108 | mock_socket.return_value.recv.side_effect = ( 109 | b'OK "deletescript completed."\r\n', 110 | ) 111 | self.assertTrue(self.client.deletescript("test_script")) 112 | 113 | def test_checkscript(self, mock_socket): 114 | """Test checkscript command.""" 115 | self.authenticate(mock_socket) 116 | mock_socket.return_value.recv.side_effect = ( 117 | b'OK "checkscript completed."\r\n', 118 | ) 119 | script = "#comment\r\nInvalidSieveCommand\r\n" 120 | self.assertTrue(self.client.checkscript(script)) 121 | 122 | def test_setactive(self, mock_socket): 123 | """Test setactive command.""" 124 | self.authenticate(mock_socket) 125 | mock_socket.return_value.recv.side_effect = (b'OK "setactive completed."\r\n',) 126 | self.assertTrue(self.client.setactive("test_script")) 127 | 128 | def test_havespace(self, mock_socket): 129 | """Test havespace command.""" 130 | self.authenticate(mock_socket) 131 | mock_socket.return_value.recv.side_effect = (b'OK "havespace completed."\r\n',) 132 | self.assertTrue(self.client.havespace("test_script", 1000)) 133 | 134 | def test_renamescript(self, mock_socket): 135 | """Test renamescript command.""" 136 | self.authenticate(mock_socket) 137 | mock_socket.return_value.recv.side_effect = ( 138 | b'OK "renamescript completed."\r\n', 139 | ) 140 | self.assertTrue(self.client.renamescript("old_script", "new_script")) 141 | 142 | def test_renamescript_simulated(self, mock_socket): 143 | """Test renamescript command simulation.""" 144 | mock_socket.return_value.recv.side_effect = ( 145 | CAPABILITIES_WITHOUT_VERSION + b'OK "Dovecot ready."\r\n' 146 | b'OK "Logged in."\r\n', 147 | ) 148 | self.client.connect("user", "password") 149 | mock_socket.return_value.recv.side_effect = ( 150 | LISTSCRIPTS, 151 | GETSCRIPT, 152 | b'OK "putscript completed."\r\n', 153 | b'OK "setactive completed."\r\n', 154 | b'OK "deletescript completed."\r\n', 155 | ) 156 | self.assertTrue(self.client.renamescript("main_script", "new_script")) 157 | 158 | 159 | if __name__ == "__main__": 160 | unittest.main() 161 | -------------------------------------------------------------------------------- /sievelib/tests/test_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for the SIEVE language parser. 3 | """ 4 | 5 | import unittest 6 | import os.path 7 | import codecs 8 | import io 9 | 10 | from sievelib.parser import Parser 11 | from sievelib.factory import FiltersSet 12 | import sievelib.commands 13 | 14 | 15 | class MytestCommand(sievelib.commands.ActionCommand): 16 | args_definition = [ 17 | { 18 | "name": "testtag", 19 | "type": ["tag"], 20 | "write_tag": True, 21 | "values": [":testtag"], 22 | "extra_arg": {"type": "number", "required": False}, 23 | "required": False, 24 | }, 25 | {"name": "recipients", "type": ["string", "stringlist"], "required": True}, 26 | ] 27 | 28 | 29 | class Quota_notificationCommand(sievelib.commands.ActionCommand): 30 | args_definition = [ 31 | { 32 | "name": "subject", 33 | "type": ["tag"], 34 | "write_tag": True, 35 | "values": [":subject"], 36 | "extra_arg": {"type": "string"}, 37 | "required": False, 38 | }, 39 | { 40 | "name": "recipient", 41 | "type": ["tag"], 42 | "write_tag": True, 43 | "values": [":recipient"], 44 | "extra_arg": {"type": "stringlist"}, 45 | "required": True, 46 | }, 47 | ] 48 | 49 | 50 | class SieveTest(unittest.TestCase): 51 | def setUp(self): 52 | self.parser = Parser() 53 | 54 | def __checkCompilation(self, script, result): 55 | self.assertEqual(self.parser.parse(script), result) 56 | 57 | def compilation_ok(self, script, **kwargs): 58 | self.__checkCompilation(script, True, **kwargs) 59 | 60 | def compilation_ko(self, script): 61 | self.__checkCompilation(script, False) 62 | 63 | def representation_is(self, content): 64 | target = io.StringIO() 65 | self.parser.dump(target) 66 | repr_ = target.getvalue() 67 | target.close() 68 | self.assertEqual(repr_, content.lstrip()) 69 | 70 | def sieve_is(self, content): 71 | filtersset = FiltersSet("Testfilterset") 72 | filtersset.from_parser_result(self.parser) 73 | target = io.StringIO() 74 | filtersset.tosieve(target) 75 | repr_ = target.getvalue() 76 | target.close() 77 | self.assertEqual(repr_, content) 78 | 79 | 80 | class AdditionalCommands(SieveTest): 81 | def test_add_command(self): 82 | self.assertRaises( 83 | sievelib.commands.UnknownCommand, 84 | sievelib.commands.get_command_instance, 85 | "mytest", 86 | ) 87 | sievelib.commands.add_commands(MytestCommand) 88 | sievelib.commands.get_command_instance("mytest") 89 | self.compilation_ok( 90 | b""" 91 | mytest :testtag 10 ["testrecp1@example.com"]; 92 | """ 93 | ) 94 | 95 | def test_quota_notification(self): 96 | sievelib.commands.add_commands(Quota_notificationCommand) 97 | quota_notification_sieve = """# Filter: Testrule\nquota_notification :subject "subject here" :recipient ["somerecipient@example.com"];\n""" 98 | self.compilation_ok(quota_notification_sieve) 99 | self.sieve_is(quota_notification_sieve) 100 | 101 | 102 | class ValidEncodings(SieveTest): 103 | def test_utf8_file(self): 104 | utf8_sieve = os.path.join(os.path.dirname(__file__), "files", "utf8_sieve.txt") 105 | with codecs.open(utf8_sieve, encoding="utf8") as fobj: 106 | source_sieve = fobj.read() 107 | self.parser.parse_file(utf8_sieve) 108 | self.sieve_is(source_sieve) 109 | 110 | 111 | class ValidSyntaxes(SieveTest): 112 | def test_hash_comment(self): 113 | self.compilation_ok( 114 | b""" 115 | if size :over 100k { # this is a comment 116 | discard; 117 | } 118 | """ 119 | ) 120 | self.representation_is( 121 | """ 122 | if (type: control) 123 | size (type: test) 124 | :over 125 | 100k 126 | discard (type: action) 127 | """ 128 | ) 129 | 130 | def test_bracket_comment(self): 131 | self.compilation_ok( 132 | b""" 133 | if size :over 100K { /* this is a comment 134 | this is still a comment */ discard /* this is a comment 135 | */ ; 136 | } 137 | """ 138 | ) 139 | self.representation_is( 140 | """ 141 | if (type: control) 142 | size (type: test) 143 | :over 144 | 100K 145 | discard (type: action) 146 | """ 147 | ) 148 | 149 | def test_string_with_bracket_comment(self): 150 | self.compilation_ok( 151 | b""" 152 | if header :contains "Cc" "/* comment */" { 153 | discard; 154 | } 155 | """ 156 | ) 157 | self.representation_is( 158 | """ 159 | if (type: control) 160 | header (type: test) 161 | :contains 162 | "Cc" 163 | "/* comment */" 164 | discard (type: action) 165 | """ 166 | ) 167 | 168 | def test_multiline_string(self): 169 | self.compilation_ok( 170 | b""" 171 | require "reject"; 172 | 173 | if allof (false, address :is ["From", "Sender"] ["blka@bla.com"]) { 174 | reject text: 175 | noreply 176 | ============================ 177 | Your email has been canceled 178 | ============================ 179 | . 180 | ; 181 | stop; 182 | } else { 183 | reject text: 184 | ================================ 185 | Your email has been canceled too 186 | ================================ 187 | . 188 | ; 189 | } 190 | """ 191 | ) 192 | self.representation_is( 193 | """ 194 | require (type: control) 195 | "reject" 196 | if (type: control) 197 | allof (type: test) 198 | false (type: test) 199 | address (type: test) 200 | :is 201 | ["From","Sender"] 202 | ["blka@bla.com"] 203 | reject (type: action) 204 | text: 205 | noreply 206 | ============================ 207 | Your email has been canceled 208 | ============================ 209 | . 210 | stop (type: action) 211 | else (type: control) 212 | reject (type: action) 213 | text: 214 | ================================ 215 | Your email has been canceled too 216 | ================================ 217 | . 218 | """ 219 | ) 220 | 221 | def test_complex_allof_with_not(self): 222 | """Test for allof/anyof commands including a not test. 223 | 224 | See https://github.com/tonioo/sievelib/issues/69. 225 | """ 226 | self.compilation_ok( 227 | b""" 228 | require ["fileinto", "reject"]; 229 | 230 | if allof (not allof (address :is ["From","sender"] ["test1@test2.priv","test2@test2.priv"], header :matches "Subject" "INACTIVE*"), address :is "From" "user3@test3.priv") 231 | { 232 | reject; 233 | } 234 | """ 235 | ) 236 | self.representation_is( 237 | """ 238 | require (type: control) 239 | ["fileinto","reject"] 240 | if (type: control) 241 | allof (type: test) 242 | not (type: test) 243 | allof (type: test) 244 | address (type: test) 245 | :is 246 | ["From","sender"] 247 | ["test1@test2.priv","test2@test2.priv"] 248 | header (type: test) 249 | :matches 250 | "Subject" 251 | "INACTIVE*" 252 | address (type: test) 253 | :is 254 | "From" 255 | "user3@test3.priv" 256 | reject (type: action) 257 | """ 258 | ) 259 | 260 | def test_nested_blocks(self): 261 | self.compilation_ok( 262 | b""" 263 | if header :contains "Sender" "example.com" { 264 | if header :contains "Sender" "me@" { 265 | discard; 266 | } elsif header :contains "Sender" "you@" { 267 | keep; 268 | } 269 | } 270 | """ 271 | ) 272 | self.representation_is( 273 | """ 274 | if (type: control) 275 | header (type: test) 276 | :contains 277 | "Sender" 278 | "example.com" 279 | if (type: control) 280 | header (type: test) 281 | :contains 282 | "Sender" 283 | "me@" 284 | discard (type: action) 285 | elsif (type: control) 286 | header (type: test) 287 | :contains 288 | "Sender" 289 | "you@" 290 | keep (type: action) 291 | """ 292 | ) 293 | 294 | def test_true_test(self): 295 | self.compilation_ok( 296 | b""" 297 | if true { 298 | 299 | } 300 | """ 301 | ) 302 | self.representation_is( 303 | """ 304 | if (type: control) 305 | true (type: test) 306 | """ 307 | ) 308 | 309 | def test_rfc5228_extended(self): 310 | self.compilation_ok( 311 | b""" 312 | # 313 | # Example Sieve Filter 314 | # Declare any optional features or extension used by the script 315 | # 316 | require ["fileinto"]; 317 | 318 | # 319 | # Handle messages from known mailing lists 320 | # Move messages from IETF filter discussion list to filter mailbox 321 | # 322 | if header :is "Sender" "owner-ietf-mta-filters@imc.org" 323 | { 324 | fileinto "filter"; # move to "filter" mailbox 325 | } 326 | # 327 | # Keep all messages to or from people in my company 328 | # 329 | elsif address :domain :is ["From", "To"] "example.com" 330 | { 331 | keep; # keep in "In" mailbox 332 | } 333 | 334 | # 335 | # Try and catch unsolicited email. If a message is not to me, 336 | # or it contains a subject known to be spam, file it away. 337 | # 338 | elsif anyof (NOT address :all :contains 339 | ["To", "Cc", "Bcc"] "me@example.com", 340 | header :matches "subject" 341 | ["*make*money*fast*", "*university*dipl*mas*"]) 342 | { 343 | fileinto "spam"; # move to "spam" mailbox 344 | } 345 | else 346 | { 347 | # Move all other (non-company) mail to "personal" 348 | # mailbox. 349 | fileinto "personal"; 350 | } 351 | """ 352 | ) 353 | self.representation_is( 354 | """ 355 | require (type: control) 356 | ["fileinto"] 357 | if (type: control) 358 | header (type: test) 359 | :is 360 | "Sender" 361 | "owner-ietf-mta-filters@imc.org" 362 | fileinto (type: action) 363 | "filter" 364 | elsif (type: control) 365 | address (type: test) 366 | :domain 367 | :is 368 | ["From","To"] 369 | "example.com" 370 | keep (type: action) 371 | elsif (type: control) 372 | anyof (type: test) 373 | not (type: test) 374 | address (type: test) 375 | :all 376 | :contains 377 | ["To","Cc","Bcc"] 378 | "me@example.com" 379 | header (type: test) 380 | :matches 381 | "subject" 382 | ["*make*money*fast*","*university*dipl*mas*"] 383 | fileinto (type: action) 384 | "spam" 385 | else (type: control) 386 | fileinto (type: action) 387 | "personal" 388 | """ 389 | ) 390 | 391 | def test_explicit_comparator(self): 392 | self.compilation_ok( 393 | b""" 394 | if header :contains :comparator "i;octet" "Subject" "MAKE MONEY FAST" { 395 | discard; 396 | } 397 | """ 398 | ) 399 | self.representation_is( 400 | """ 401 | if (type: control) 402 | header (type: test) 403 | :comparator 404 | "i;octet" 405 | :contains 406 | "Subject" 407 | "MAKE MONEY FAST" 408 | discard (type: action) 409 | """ 410 | ) 411 | 412 | def test_non_ordered_args(self): 413 | self.compilation_ok( 414 | b""" 415 | if address :all :is "from" "tim@example.com" { 416 | discard; 417 | } 418 | """ 419 | ) 420 | self.representation_is( 421 | """ 422 | if (type: control) 423 | address (type: test) 424 | :all 425 | :is 426 | "from" 427 | "tim@example.com" 428 | discard (type: action) 429 | """ 430 | ) 431 | 432 | def test_multiple_not(self): 433 | self.compilation_ok( 434 | b""" 435 | if not not not not true { 436 | stop; 437 | } 438 | """ 439 | ) 440 | self.representation_is( 441 | """ 442 | if (type: control) 443 | not (type: test) 444 | not (type: test) 445 | not (type: test) 446 | not (type: test) 447 | true (type: test) 448 | stop (type: action) 449 | """ 450 | ) 451 | 452 | def test_just_one_command(self): 453 | self.compilation_ok(b"keep;") 454 | self.representation_is( 455 | """ 456 | keep (type: action) 457 | """ 458 | ) 459 | 460 | def test_singletest_testlist(self): 461 | self.compilation_ok( 462 | b""" 463 | if anyof (true) { 464 | discard; 465 | } 466 | """ 467 | ) 468 | self.representation_is( 469 | """ 470 | if (type: control) 471 | anyof (type: test) 472 | true (type: test) 473 | discard (type: action) 474 | """ 475 | ) 476 | 477 | def test_multitest_testlist(self): 478 | self.compilation_ok( 479 | b""" 480 | if anyof(allof(address :contains "From" ""), allof(header :contains "Subject" "")) {} 481 | """ 482 | ) 483 | 484 | def test_truefalse_testlist(self): 485 | self.compilation_ok( 486 | b""" 487 | if anyof(true, false) { 488 | discard; 489 | } 490 | """ 491 | ) 492 | self.representation_is( 493 | """ 494 | if (type: control) 495 | anyof (type: test) 496 | true (type: test) 497 | false (type: test) 498 | discard (type: action) 499 | """ 500 | ) 501 | 502 | def test_vacationext_basic(self): 503 | self.compilation_ok( 504 | b""" 505 | require "vacation"; 506 | if header :contains "subject" "cyrus" { 507 | vacation "I'm out -- send mail to cyrus-bugs"; 508 | } else { 509 | vacation "I'm out -- call me at +1 304 555 0123"; 510 | } 511 | """ 512 | ) 513 | 514 | def test_vacationext_medium(self): 515 | self.compilation_ok( 516 | b""" 517 | require "vacation"; 518 | if header :contains "subject" "lunch" { 519 | vacation :handle "ran-away" "I'm out and can't meet for lunch"; 520 | } else { 521 | vacation :handle "ran-away" "I'm out"; 522 | } 523 | """ 524 | ) 525 | 526 | def test_vacationext_with_limit(self): 527 | self.compilation_ok( 528 | b""" 529 | require "vacation"; 530 | vacation :days 23 :addresses ["tjs@example.edu", 531 | "ts4z@landru.example.edu"] 532 | "I'm away until October 19. 533 | If it's an emergency, call 911, I guess." ; 534 | """ 535 | ) 536 | 537 | def test_vacationext_with_single_mail_address(self): 538 | self.compilation_ok( 539 | """ 540 | require "vacation"; 541 | vacation :days 23 :addresses "tjs@example.edu" 542 | "I'm away until October 19. 543 | If it's an emergency, call 911, I guess." ; 544 | """ 545 | ) 546 | 547 | def test_vacationext_with_multiline(self): 548 | self.compilation_ok( 549 | b""" 550 | require "vacation"; 551 | vacation :mime text: 552 | Content-Type: multipart/alternative; boundary=foo 553 | 554 | --foo 555 | 556 | I'm at the beach relaxing. Mmmm, surf... 557 | 558 | --foo 559 | Content-Type: text/html; charset=us-ascii 560 | 561 | 563 | How to relax 564 | 565 |

I'm at the beach relaxing. 566 | Mmmm, surf... 567 | 568 | 569 | --foo-- 570 | . 571 | ; 572 | """ 573 | ) 574 | 575 | def test_vacation_seconds(self): 576 | self.compilation_ok( 577 | """ 578 | require ["vacation", "vacation-seconds"]; 579 | vacation :seconds 10 :addresses ["test@example.org"] "Gone"; 580 | """ 581 | ) 582 | 583 | def test_reject_extension(self): 584 | self.compilation_ok( 585 | b""" 586 | require "reject"; 587 | 588 | if header :contains "subject" "viagra" { 589 | reject; 590 | } 591 | """ 592 | ) 593 | 594 | def test_fileinto_create(self): 595 | self.compilation_ok( 596 | b"""require ["fileinto", "mailbox"]; 597 | if header :is "Sender" "owner-ietf-mta-filters@imc.org" 598 | { 599 | fileinto :create "filter"; # move to "filter" mailbox 600 | } 601 | """ 602 | ) 603 | 604 | def test_imap4flags_extension(self): 605 | self.compilation_ok( 606 | rb""" 607 | require ["fileinto", "imap4flags", "variables"]; 608 | if size :over 1M { 609 | addflag "MyFlags" "Big"; 610 | if header :is "From" "boss@company.example.com" { 611 | # The message will be marked as "\Flagged Big" when filed into 612 | # mailbox "Big messages" 613 | addflag "MyFlags" "\\Flagged"; 614 | } 615 | fileinto :flags "${MyFlags}" "Big messages"; 616 | } 617 | """ 618 | ) 619 | 620 | def test_imap4flags_hasflag(self): 621 | self.compilation_ok( 622 | b""" 623 | require ["imap4flags", "fileinto"]; 624 | 625 | if hasflag ["test", "toto"] { 626 | fileinto "Test"; 627 | } 628 | addflag "Var1" "Truc"; 629 | if hasflag "Var1" "Truc" { 630 | fileinto "Truc"; 631 | } 632 | """ 633 | ) 634 | 635 | def test_body_extension(self): 636 | self.compilation_ok( 637 | b""" 638 | require ["body", "fileinto"]; 639 | 640 | if body :content "text" :contains ["missile", "coordinates"] { 641 | fileinto "secrets"; 642 | } 643 | """ 644 | ) 645 | self.compilation_ok( 646 | b""" 647 | require "body"; 648 | 649 | if body :raw :contains "MAKE MONEY FAST" { 650 | discard; 651 | } 652 | """ 653 | ) 654 | self.compilation_ok( 655 | b""" 656 | require ["body", "fileinto"]; 657 | 658 | # Save messages mentioning the project schedule in the 659 | # project/schedule folder. 660 | if body :text :contains "project schedule" { 661 | fileinto "project/schedule"; 662 | } 663 | """ 664 | ) 665 | 666 | def test_notify_extension(self): 667 | self.compilation_ok( 668 | b"""require ["enotify", "fileinto", "variables"]; 669 | 670 | if header :contains "from" "boss@example.org" { 671 | notify :importance "1" 672 | :message "This is probably very important" 673 | "mailto:alm@example.com"; 674 | # Don't send any further notifications 675 | stop; 676 | } 677 | """ 678 | ) 679 | 680 | class InvalidSyntaxes(SieveTest): 681 | def test_nested_comments(self): 682 | self.compilation_ko( 683 | b""" 684 | /* this is a comment /* with a nested comment inside */ 685 | it is allowed by the RFC :p */ 686 | """ 687 | ) 688 | 689 | def test_nonopened_block(self): 690 | self.compilation_ko( 691 | b""" 692 | if header :is "Sender" "me@example.com" 693 | discard; 694 | } 695 | """ 696 | ) 697 | 698 | def test_nonclosed_block(self): 699 | self.compilation_ko( 700 | b""" 701 | if header :is "Sender" "me@example.com" { 702 | discard; 703 | 704 | """ 705 | ) 706 | 707 | def test_nonopened_parenthesis(self): 708 | self.compilation_ko( 709 | b""" 710 | if header :is "Sender" "me@example.com") { 711 | discard; 712 | } 713 | """ 714 | ) 715 | 716 | def test_nonopened_block2(self): 717 | self.compilation_ko(b"""}""") 718 | 719 | def test_unknown_token(self): 720 | self.compilation_ko( 721 | b""" 722 | if header :is "Sender" "Toto" & header :contains "Cc" "Tata" { 723 | 724 | } 725 | """ 726 | ) 727 | 728 | def test_empty_string_list(self): 729 | self.compilation_ko(b"require [];") 730 | 731 | def test_unopened_string_list(self): 732 | self.compilation_ko(b'require "fileinto"];') 733 | 734 | def test_unclosed_string_list(self): 735 | self.compilation_ko(b'require ["toto", "tata";') 736 | 737 | def test_misplaced_comma_in_string_list(self): 738 | self.compilation_ko(b'require ["toto",];') 739 | 740 | def test_nonopened_tests_list(self): 741 | self.compilation_ko( 742 | b""" 743 | if anyof header :is "Sender" "me@example.com", 744 | header :is "Sender" "myself@example.com") { 745 | fileinto "trash"; 746 | } 747 | """ 748 | ) 749 | 750 | def test_nonclosed_tests_list(self): 751 | self.compilation_ko( 752 | b""" 753 | if anyof (header :is "Sender" "me@example.com", 754 | header :is "Sender" "myself@example.com" { 755 | fileinto "trash"; 756 | } 757 | """ 758 | ) 759 | 760 | def test_nonclosed_tests_list2(self): 761 | self.compilation_ko( 762 | b""" 763 | if anyof (header :is "Sender" { 764 | fileinto "trash"; 765 | } 766 | """ 767 | ) 768 | 769 | def test_misplaced_comma_in_tests_list(self): 770 | self.compilation_ko( 771 | b""" 772 | if anyof (header :is "Sender" "me@example.com",) { 773 | 774 | } 775 | """ 776 | ) 777 | 778 | def test_comma_inside_arguments(self): 779 | self.compilation_ko( 780 | b""" 781 | require "fileinto", "enveloppe"; 782 | """ 783 | ) 784 | 785 | def test_non_ordered_args(self): 786 | self.compilation_ko( 787 | b""" 788 | if address "From" :is "tim@example.com" { 789 | discard; 790 | } 791 | """ 792 | ) 793 | 794 | def test_extra_arg(self): 795 | self.compilation_ko( 796 | b""" 797 | if address :is "From" "tim@example.com" "tutu" { 798 | discard; 799 | } 800 | """ 801 | ) 802 | 803 | def test_empty_not(self): 804 | self.compilation_ko( 805 | b""" 806 | if not { 807 | discard; 808 | } 809 | """ 810 | ) 811 | 812 | def test_missing_semicolon(self): 813 | self.compilation_ko( 814 | b""" 815 | require ["fileinto"] 816 | """ 817 | ) 818 | 819 | def test_missing_semicolon_in_block(self): 820 | self.compilation_ko( 821 | b""" 822 | if true { 823 | stop 824 | } 825 | """ 826 | ) 827 | 828 | def test_misplaced_parenthesis(self): 829 | self.compilation_ko( 830 | b""" 831 | if (true) { 832 | 833 | } 834 | """ 835 | ) 836 | 837 | def test_control_command_in_test(self): 838 | self.compilation_ko( 839 | b""" 840 | if stop; 841 | """ 842 | ) 843 | 844 | def test_extra_test_in_simple_control(self): 845 | self.compilation_ko( 846 | b""" 847 | if address "From" "example.com" header "Subject" "Example" { stop; } 848 | """ 849 | ) 850 | 851 | def test_missing_comma_in_test_list(self): 852 | self.compilation_ko( 853 | b""" 854 | if allof(anyof(address "From" "example.com") header "Subject" "Example") { stop; } 855 | """ 856 | ) 857 | 858 | def test_vacation_seconds_no_arg(self): 859 | self.compilation_ko( 860 | """ 861 | require ["vacation", "vacation-seconds"]; 862 | vacation :seconds :addresses ["test@example.org"] "Gone"; 863 | """ 864 | ) 865 | 866 | def test_notify_extension_importance_no_args(self): 867 | self.compilation_ko( 868 | b"""require ["enotify", "fileinto", "variables"]; 869 | 870 | if header :contains "from" "boss@example.org" { 871 | notify :importance 872 | :message "This is probably very important"; 873 | "mailto:alm@example.com" 874 | # Don't send any further notifications 875 | stop; 876 | } 877 | """ 878 | ) 879 | 880 | class LanguageRestrictions(SieveTest): 881 | def test_unknown_control(self): 882 | self.compilation_ko( 883 | b""" 884 | macommande "Toto"; 885 | """ 886 | ) 887 | 888 | def test_misplaced_elsif(self): 889 | self.compilation_ko( 890 | b""" 891 | elsif true { 892 | 893 | } 894 | """ 895 | ) 896 | 897 | def test_misplaced_elsif2(self): 898 | self.compilation_ko( 899 | b""" 900 | elsif header :is "From" "toto" { 901 | 902 | } 903 | """ 904 | ) 905 | 906 | def test_misplaced_nested_elsif(self): 907 | self.compilation_ko( 908 | b""" 909 | if true { 910 | elsif false { 911 | 912 | } 913 | } 914 | """ 915 | ) 916 | 917 | def test_unexpected_argument(self): 918 | self.compilation_ko(b'stop "toto";') 919 | 920 | def test_bad_arg_value(self): 921 | self.compilation_ko( 922 | b""" 923 | if header :isnot "Sent" "me@example.com" { 924 | stop; 925 | } 926 | """ 927 | ) 928 | 929 | def test_bad_arg_value2(self): 930 | self.compilation_ko( 931 | b""" 932 | if header :isnot "Sent" 10000 { 933 | stop; 934 | } 935 | """ 936 | ) 937 | 938 | def test_bad_comparator_value(self): 939 | self.compilation_ko( 940 | b""" 941 | if header :contains :comparator "i;prout" "Subject" "MAKE MONEY FAST" { 942 | discard; 943 | } 944 | """ 945 | ) 946 | 947 | def test_not_included_extension(self): 948 | self.compilation_ko( 949 | b""" 950 | if header :contains "Subject" "MAKE MONEY FAST" { 951 | fileinto "spam"; 952 | } 953 | """ 954 | ) 955 | 956 | def test_test_outside_control(self): 957 | self.compilation_ko(b"true;") 958 | 959 | def test_fileinto_create_without_mailbox(self): 960 | self.compilation_ko( 961 | b"""require ["fileinto"]; 962 | if header :is "Sender" "owner-ietf-mta-filters@imc.org" 963 | { 964 | fileinto :create "filter"; # move to "filter" mailbox 965 | } 966 | """ 967 | ) 968 | self.assertEqual(self.parser.error, "line 4: extension 'mailbox' not loaded") 969 | 970 | def test_fileinto_create_without_fileinto(self): 971 | self.compilation_ko( 972 | b"""require ["mailbox"]; 973 | if header :is "Sender" "owner-ietf-mta-filters@imc.org" 974 | { 975 | fileinto :create "filter"; # move to "filter" mailbox 976 | } 977 | """ 978 | ) 979 | self.assertEqual(self.parser.error, "line 4: extension 'fileinto' not loaded") 980 | 981 | def test_unknown_command(self): 982 | self.compilation_ko( 983 | b"""require ["mailbox"]; 984 | if header :is "Sender" "owner-ietf-mta-filters@imc.org" 985 | { 986 | foobar :create "filter"; # move to "filter" mailbox 987 | } 988 | """ 989 | ) 990 | self.assertEqual(self.parser.error, "line 4: unknown command 'foobar'") 991 | 992 | def test_exists_get_string_or_list(self): 993 | self.compilation_ok( 994 | b""" 995 | if exists "subject" 996 | { 997 | discard; 998 | } 999 | """ 1000 | ) 1001 | self.compilation_ok( 1002 | b""" 1003 | if exists ["subject"] 1004 | { 1005 | discard; 1006 | } 1007 | """ 1008 | ) 1009 | 1010 | 1011 | class DateCommands(SieveTest): 1012 | 1013 | def test_date_command(self): 1014 | self.compilation_ok( 1015 | b"""require ["date", "relational", "fileinto"]; 1016 | if allof(header :is "from" "boss@example.com", 1017 | date :value "ge" :originalzone "date" "hour" "09", 1018 | date :value "lt" :originalzone "date" "hour" "17") 1019 | { fileinto "urgent"; } 1020 | """ 1021 | ) 1022 | 1023 | def test_currentdate_command(self): 1024 | self.compilation_ok( 1025 | b"""require ["date", "relational"]; 1026 | 1027 | if allof(currentdate :value "ge" "date" "2013-10-23", 1028 | currentdate :value "le" "date" "2014-10-12") 1029 | { 1030 | discard; 1031 | } 1032 | """ 1033 | ) 1034 | 1035 | def test_currentdate_command_timezone(self): 1036 | self.compilation_ok( 1037 | b"""require ["date", "relational"]; 1038 | 1039 | if allof(currentdate :zone "+0100" :value "ge" "date" "2013-10-23", 1040 | currentdate :value "le" "date" "2014-10-12") 1041 | { 1042 | discard; 1043 | } 1044 | """ 1045 | ) 1046 | 1047 | def test_currentdate_norel(self): 1048 | self.compilation_ok( 1049 | b"""require ["date"]; 1050 | 1051 | if allof ( 1052 | currentdate :zone "+0100" :is "date" "2013-10-23" 1053 | ) 1054 | { 1055 | discard; 1056 | }""" 1057 | ) 1058 | 1059 | def test_currentdate_extension_not_loaded(self): 1060 | self.compilation_ko( 1061 | b"""require ["date"]; 1062 | 1063 | if allof ( currentdate :value "ge" "date" "2013-10-23" , currentdate :value "le" "date" "2014-10-12" ) 1064 | { 1065 | discard; 1066 | } 1067 | """ 1068 | ) 1069 | 1070 | 1071 | class VariablesCommands(SieveTest): 1072 | def test_set_command(self): 1073 | self.compilation_ok( 1074 | b"""require ["variables"]; 1075 | 1076 | set "matchsub" "testsubject"; 1077 | 1078 | if allof ( 1079 | header :contains ["Subject"] "${header}" 1080 | ) 1081 | { 1082 | discard; 1083 | } 1084 | """ 1085 | ) 1086 | 1087 | 1088 | class CopyWithoutSideEffectsTestCase(SieveTest): 1089 | """RFC3894 test cases.""" 1090 | 1091 | def test_redirect_with_copy(self): 1092 | self.compilation_ko( 1093 | b""" 1094 | if header :contains "subject" "test" { 1095 | redirect :copy "dev@null.com"; 1096 | } 1097 | """ 1098 | ) 1099 | 1100 | self.compilation_ok( 1101 | b"""require "copy"; 1102 | if header :contains "subject" "test" { 1103 | redirect :copy "dev@null.com"; 1104 | } 1105 | """ 1106 | ) 1107 | 1108 | def test_fileinto_with_copy(self): 1109 | self.compilation_ko( 1110 | b"""require "fileinto"; 1111 | if header :contains "subject" "test" { 1112 | fileinto :copy "Spam"; 1113 | } 1114 | """ 1115 | ) 1116 | self.assertEqual(self.parser.error, "line 3: extension 'copy' not loaded") 1117 | 1118 | self.compilation_ok( 1119 | b"""require ["fileinto", "copy"]; 1120 | if header :contains "subject" "test" { 1121 | fileinto :copy "Spam"; 1122 | } 1123 | """ 1124 | ) 1125 | 1126 | 1127 | class RegexMatchTestCase(SieveTest): 1128 | def test_header_regex(self): 1129 | self.compilation_ok( 1130 | b"""require "regex"; 1131 | if header :regex "Subject" "^Test" { 1132 | discard; 1133 | } 1134 | """ 1135 | ) 1136 | 1137 | def test_header_regex_no_middle(self): 1138 | self.compilation_ko( 1139 | b"""require "regex"; 1140 | if header "Subject" :regex "^Test" { 1141 | discard; 1142 | } 1143 | """ 1144 | ) 1145 | 1146 | def test_envelope_regex(self): 1147 | self.compilation_ok( 1148 | b"""require ["regex","envelope"]; 1149 | if envelope :regex "from" "^test@example\\.org$" { 1150 | discard; 1151 | } 1152 | """ 1153 | ) 1154 | 1155 | def test_envelope_regex_no_middle(self): 1156 | self.compilation_ko( 1157 | b"""require "regex"; 1158 | if envelope "from" :regex "^test@example\\.org$" { 1159 | discard; 1160 | } 1161 | """ 1162 | ) 1163 | 1164 | def test_address_regex(self): 1165 | self.compilation_ok( 1166 | b"""require "regex"; 1167 | if address :regex "from" "^test@example\\.org$" { 1168 | discard; 1169 | } 1170 | """ 1171 | ) 1172 | 1173 | def test_address_regex_no_middle(self): 1174 | self.compilation_ko( 1175 | b"""require "regex"; 1176 | if address "from" :regex "^test@example\\.org$" { 1177 | discard; 1178 | } 1179 | """ 1180 | ) 1181 | 1182 | def test_body_raw_regex(self): 1183 | self.compilation_ok( 1184 | b"""require ["body", "regex"]; 1185 | if body :raw :regex "Sample" { 1186 | discard; 1187 | } 1188 | """ 1189 | ) 1190 | 1191 | def test_body_content_regex(self): 1192 | self.compilation_ok( 1193 | b"""require ["body", "regex"]; 1194 | if body :content "text" :regex "Sample" { 1195 | discard; 1196 | } 1197 | """ 1198 | ) 1199 | 1200 | 1201 | if __name__ == "__main__": 1202 | unittest.main() 1203 | -------------------------------------------------------------------------------- /sievelib/tools.py: -------------------------------------------------------------------------------- 1 | """Some tools.""" 2 | 3 | from typing import List 4 | 5 | 6 | def to_list(stringlist: str, unquote: bool = True) -> List[str]: 7 | """Convert a string representing a list to real list.""" 8 | stringlist = stringlist[1:-1] 9 | return [ 10 | string.strip('"') if unquote else string for string in stringlist.split(",") 11 | ] 12 | --------------------------------------------------------------------------------