├── sievelib ├── __init__.py ├── tests │ ├── __init__.py │ ├── files │ │ └── utf8_sieve.txt │ ├── test_factory.py │ └── test_parser.py ├── digest_md5.py ├── factory.py ├── parser.py ├── managesieve.py └── commands.py ├── requirements.txt ├── MANIFEST.in ├── .gitignore ├── .travis.yml ├── COPYING ├── setup.py └── README.rst /sievelib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | six 2 | -------------------------------------------------------------------------------- /sievelib/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include COPYING 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*~$ 2 | *.pyc 3 | dist 4 | sievelib.egg-info 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | 6 | install: 7 | - pip install nose --use-mirrors 8 | - python setup.py -q install 9 | 10 | script: nosetests 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2013, Antoine Nguyen 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /sievelib/digest_md5.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | Simple Digest-MD5 implementation (client side) 5 | 6 | Implementation based on RFC 2831 (http://www.ietf.org/rfc/rfc2831.txt) 7 | """ 8 | 9 | import base64 10 | import hashlib 11 | import binascii 12 | import re 13 | import random 14 | 15 | 16 | class DigestMD5(object): 17 | def __init__(self, challenge, digesturi): 18 | self.__digesturi = digesturi 19 | self.__challenge = challenge 20 | 21 | self.__params = {} 22 | pexpr = re.compile('(\w+)="(.+)"') 23 | for elt in base64.b64decode(challenge).split(","): 24 | m = pexpr.match(elt) 25 | if m is None: 26 | continue 27 | self.__params[m.group(1)] = m.group(2) 28 | 29 | def __make_cnonce(self): 30 | ret = "" 31 | for i in xrange(12): 32 | ret += chr(random.randint(0, 0xff)) 33 | return base64.b64encode(ret) 34 | 35 | def __digest(self, value): 36 | return hashlib.md5(value).digest() 37 | 38 | def __hexdigest(self, value): 39 | return binascii.hexlify(hashlib.md5(value).digest()) 40 | 41 | def __make_response(self, username, password, check=False): 42 | a1 = "%s:%s:%s" % (self.__digest("%s:%s:%s" % (username, self.realm, password)), 43 | self.__params["nonce"], self.cnonce) 44 | if check: 45 | a2 = ":%s" % self.__digesturi 46 | else: 47 | a2 = "AUTHENTICATE:%s" % self.__digesturi 48 | resp = "%s:%s:00000001:%s:auth:%s" \ 49 | % (self.__hexdigest(a1), self.__params["nonce"], 50 | self.cnonce, self.__hexdigest(a2)) 51 | 52 | return self.__hexdigest(resp) 53 | 54 | def response(self, username, password): 55 | self.realm = self.__params["realm"] if self.__params.has_key("realm") else "" 56 | self.cnonce = self.__make_cnonce() 57 | respvalue = self.__make_response(username, password) 58 | 59 | dgres = 'username="%s",%snonce="%s",cnonce="%s",nc=00000001,qop=auth,' \ 60 | 'digest-uri="%s",response=%s' \ 61 | % (username, ('realm="%s",' % self.realm) if len(self.realm) else "", 62 | self.__params["nonce"], self.cnonce, self.__digesturi, respvalue) 63 | 64 | return base64.b64encode(dgres) 65 | 66 | def check_last_challenge(self, username, password, value): 67 | challenge = base64.b64decode(value.strip('"')) 68 | return challenge == \ 69 | ("rspauth=%s" % self.__make_response(username, password, True)) 70 | 71 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import os 3 | import re 4 | from setuptools import setup, find_packages 5 | 6 | ROOT = os.path.dirname(__file__) 7 | PIP_REQUIRES = os.path.join(ROOT, "requirements.txt") 8 | 9 | 10 | def parse_requirements(*filenames): 11 | """ 12 | We generate our install_requires from the pip-requires and test-requires 13 | files so that we don't have to maintain the dependency definitions in 14 | two places. 15 | """ 16 | requirements = [] 17 | for f in filenames: 18 | for line in open(f, 'r').read().split('\n'): 19 | # Comment lines. Skip. 20 | if re.match(r'(\s*#)|(\s*$)', line): 21 | continue 22 | # Editable matches. Put the egg name into our reqs list. 23 | if re.match(r'\s*-e\s+', line): 24 | pkg = re.sub(r'\s*-e\s+.*#egg=(.*)$', r'\1', line) 25 | requirements.append("%s" % pkg) 26 | # File-based installs not supported/needed. Skip. 27 | elif re.match(r'\s*-f\s+', line): 28 | pass 29 | else: 30 | requirements.append(line) 31 | return requirements 32 | 33 | 34 | def parse_dependency_links(*filenames): 35 | """ 36 | We generate our dependency_links from the pip-requires and test-requires 37 | files for the dependencies pulled from github (prepended with -e). 38 | """ 39 | dependency_links = [] 40 | for f in filenames: 41 | for line in open(f, 'r').read().split('\n'): 42 | if re.match(r'\s*-[ef]\s+', line): 43 | line = re.sub(r'\s*-[ef]\s+', '', line) 44 | line = re.sub(r'\s*git\+https', 'http', line) 45 | line = re.sub(r'\.git#', '/tarball/master#', line) 46 | dependency_links.append(line) 47 | return dependency_links 48 | 49 | 50 | def read(fname): 51 | return open(os.path.join(ROOT, fname)).read() 52 | 53 | setup( 54 | name="sievelib", 55 | packages=find_packages(), 56 | include_package_data=True, 57 | version="0.8", 58 | description="Client-side SIEVE library", 59 | author="Antoine Nguyen", 60 | author_email="tonio@ngyn.org", 61 | url="https://github.com/tonioo/sievelib", 62 | license="MIT", 63 | keywords=["sieve", "managesieve", "parser", "client"], 64 | install_requires=parse_requirements(PIP_REQUIRES), 65 | dependency_links=parse_dependency_links(PIP_REQUIRES), 66 | classifiers=[ 67 | "Programming Language :: Python", 68 | "Development Status :: 4 - Beta", 69 | "Intended Audience :: Developers", 70 | "License :: OSI Approved :: MIT License", 71 | "Operating System :: OS Independent", 72 | "Topic :: Software Development :: Libraries :: Python Modules", 73 | "Topic :: Communications :: Email :: Filters" 74 | ], 75 | long_description=read("README.rst") 76 | ) 77 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | sievelib 2 | ======== 3 | 4 | |travis| |latest-version| |downloads| 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 | Sieve tools 14 | ----------- 15 | 16 | What is supported 17 | ^^^^^^^^^^^^^^^^^ 18 | 19 | Currently, the provided parser supports most of the functionalities 20 | described in the RFC. The only exception concerns section 21 | *2.4.2.4. Encoding Characters Using "encoded-character"* which is not 22 | supported. 23 | 24 | The following extensions are also supported: 25 | 26 | * Date and Index (`RFC 5260 `_) 27 | * Vacation (`RFC 5230 `_) 28 | 29 | Extending the parser 30 | ^^^^^^^^^^^^^^^^^^^^ 31 | 32 | It is possible to extend the parser by adding new supported 33 | commands. For example:: 34 | 35 | import sievelib 36 | 37 | def MyCommand(sievelib.commands.ActionCommand): 38 | args_definition = [ 39 | {"name": "testtag", 40 | "type": ["tag"], 41 | "write_tag": True, 42 | "values": [":testtag"], 43 | "extra_arg": {"type": "number", 44 | "required": False}, 45 | "required": False}, 46 | {"name": "recipients", 47 | "type": ["string", "stringlist"], 48 | "required": True} 49 | ] 50 | 51 | sievelib.commands.add_commands(MyCommand) 52 | 53 | Basic usage 54 | ^^^^^^^^^^^ 55 | 56 | The parser can either be used from the command-line:: 57 | 58 | $ cd sievelib 59 | $ python parser.py test.sieve 60 | Syntax OK 61 | $ 62 | 63 | Or can be used from a python environment (or script/module):: 64 | 65 | >>> from sievelib.parser import Parser 66 | >>> p = Parser() 67 | >>> p.parse('require ["fileinto"];') 68 | True 69 | >>> p.dump() 70 | require (type: control) 71 | ["fileinto"] 72 | >>> 73 | >>> p.parse('require ["fileinto"]') 74 | False 75 | >>> p.error 76 | 'line 1: parsing error: end of script reached while semicolon expected' 77 | >>> 78 | 79 | Simple filters creation 80 | ^^^^^^^^^^^^^^^^^^^^^^^ 81 | 82 | Some high-level classes are provided with the ``factory`` module, they 83 | make the generation of Sieve rules easier:: 84 | 85 | >>> from sievelib.factory import FiltersSet 86 | >>> fs = FiltersSet("test") 87 | >>> fs.addfilter("rule1", 88 | ... [("Sender", ":is", "toto@toto.com"),], 89 | ... [("fileinto", "Toto"),]) 90 | >>> fs.tosieve() 91 | require ["fileinto"]; 92 | 93 | # Filter: rule1 94 | if anyof (header :is "Sender" "toto@toto.com") { 95 | fileinto "Toto"; 96 | } 97 | >>> 98 | 99 | Additional documentation is available within source code. 100 | 101 | ManageSieve tools 102 | ----------------- 103 | 104 | What is supported 105 | ^^^^^^^^^^^^^^^^^ 106 | 107 | All mandatory commands are supported. The ``RENAME`` extension is 108 | supported, with a simulated behaviour for server that do not support 109 | it. 110 | 111 | For the ``AUTHENTICATE`` command, supported mechanisms are ``DIGEST-MD5``, 112 | ``PLAIN`` and ``LOGIN``. 113 | 114 | Basic usage 115 | ^^^^^^^^^^^ 116 | 117 | The ManageSieve client is intended to be used from another python 118 | application (there isn't any shell provided):: 119 | 120 | >>> from sievelib.managesieve import Client 121 | >>> c = Client("server.example.com") 122 | >>> c.connect("user", "password", starttls=False, authmech="DIGEST-MD5") 123 | True 124 | >>> c.listscripts() 125 | ("active_script", ["script1", "script2"]) 126 | >>> c.setactive("script1") 127 | True 128 | >>> c.havespace("script3", 45) 129 | True 130 | >>> 131 | 132 | Additionnal documentation is available with source code. 133 | 134 | .. |latest-version| image:: https://pypip.in/v/sievelib/badge.png 135 | :alt: Latest version on Pypi 136 | :target: https://crate.io/packages/sievelib/ 137 | .. |downloads| image:: https://pypip.in/d/sievelib/badge.png 138 | :alt: Downloads from Pypi 139 | :target: https://crate.io/packages/sievelib/ 140 | .. |travis| image:: https://travis-ci.org/tonioo/sievelib.png?branch=master 141 | :target: https://travis-ci.org/tonioo/sievelib 142 | -------------------------------------------------------------------------------- /sievelib/tests/test_factory.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals 3 | 4 | import unittest 5 | import six 6 | from sievelib.factory import FiltersSet 7 | 8 | 9 | class FactoryTestCase(unittest.TestCase): 10 | 11 | def setUp(self): 12 | self.fs = FiltersSet("test") 13 | 14 | def test_add_header_filter(self): 15 | output = six.StringIO() 16 | self.fs.addfilter("rule1", 17 | [('Sender', ":is", 'toto@toto.com'),], 18 | [("fileinto", 'Toto'),]) 19 | self.assertIsNot(self.fs.getfilter("rule1"), None) 20 | self.fs.tosieve(output) 21 | self.assertEqual(output.getvalue(), """require ["fileinto"]; 22 | 23 | # Filter: rule1 24 | if anyof (header :is "Sender" "toto@toto.com") { 25 | fileinto "Toto"; 26 | } 27 | """) 28 | output.close() 29 | 30 | def test_add_header_filter_with_not(self): 31 | output = six.StringIO() 32 | self.fs.addfilter("rule1", 33 | [('Sender', ":notcontains", 'toto@toto.com'),], 34 | [("fileinto", 'Toto'),]) 35 | self.assertIsNot(self.fs.getfilter("rule1"), None) 36 | self.fs.tosieve(output) 37 | self.assertEqual(output.getvalue(), """require ["fileinto"]; 38 | 39 | # Filter: rule1 40 | if anyof (not header :contains "Sender" "toto@toto.com") { 41 | fileinto "Toto"; 42 | } 43 | """) 44 | 45 | def test_add_exists_filter(self): 46 | output = six.StringIO() 47 | self.fs.addfilter( 48 | "rule1", 49 | [('exists', "list-help", "list-unsubscribe", "list-subscribe", "list-owner")], 50 | [("fileinto", 'Toto'),] 51 | ) 52 | self.assertIsNot(self.fs.getfilter("rule1"), None) 53 | self.fs.tosieve(output) 54 | self.assertEqual(output.getvalue(), """require ["fileinto"]; 55 | 56 | # Filter: rule1 57 | if anyof (exists ["list-help","list-unsubscribe","list-subscribe","list-owner"]) { 58 | fileinto "Toto"; 59 | } 60 | """) 61 | 62 | def test_add_exists_filter_with_not(self): 63 | output = six.StringIO() 64 | self.fs.addfilter( 65 | "rule1", 66 | [('notexists', "list-help", "list-unsubscribe", "list-subscribe", "list-owner")], 67 | [("fileinto", 'Toto'),] 68 | ) 69 | self.assertIsNot(self.fs.getfilter("rule1"), None) 70 | self.fs.tosieve(output) 71 | self.assertEqual(output.getvalue(), """require ["fileinto"]; 72 | 73 | # Filter: rule1 74 | if anyof (not exists ["list-help","list-unsubscribe","list-subscribe","list-owner"]) { 75 | fileinto "Toto"; 76 | } 77 | """) 78 | 79 | def test_add_size_filter(self): 80 | output = six.StringIO() 81 | self.fs.addfilter( 82 | "rule1", 83 | [('size', ":over", "100k")], 84 | [("fileinto", 'Totoéé'),] 85 | ) 86 | self.assertIsNot(self.fs.getfilter("rule1"), None) 87 | self.fs.tosieve(output) 88 | self.assertEqual(output.getvalue(), """require ["fileinto"]; 89 | 90 | # Filter: rule1 91 | if anyof (size :over 100k) { 92 | fileinto "Totoéé"; 93 | } 94 | """) 95 | 96 | def test_remove_filter(self): 97 | self.fs.addfilter("rule1", 98 | [('Sender', ":is", 'toto@toto.com'),], 99 | [("fileinto", 'Toto'),]) 100 | self.assertIsNot(self.fs.getfilter("rule1"), None) 101 | self.assertEqual(self.fs.removefilter("rule1"), True) 102 | self.assertIs(self.fs.getfilter("rule1"), None) 103 | 104 | 105 | def test_disablefilter(self): 106 | """ 107 | FIXME: Extra spaces are written between if and anyof, why?! 108 | """ 109 | self.fs.addfilter("rule1", 110 | [('Sender', ":is", 'toto@toto.com'),], 111 | [("fileinto", 'Toto'),]) 112 | self.assertIsNot(self.fs.getfilter("rule1"), None) 113 | self.assertEqual(self.fs.disablefilter("rule1"), True) 114 | output = six.StringIO() 115 | self.fs.tosieve(output) 116 | self.assertEqual(output.getvalue(), """require ["fileinto"]; 117 | 118 | # Filter: rule1 119 | if false { 120 | if anyof (header :is "Sender" "toto@toto.com") { 121 | fileinto "Toto"; 122 | } 123 | } 124 | """) 125 | output.close() 126 | self.assertEqual(self.fs.is_filter_disabled("rule1"), True) 127 | 128 | if __name__ == "__main__": 129 | unittest.main() 130 | -------------------------------------------------------------------------------- /sievelib/factory.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | Tools for simpler sieve filters generation. 5 | 6 | This module is intented to facilitate the creation of sieve filters 7 | without having to write or to know the syntax. 8 | 9 | Only commands (control/test/action) defined in the ``commands`` module 10 | are supported. 11 | """ 12 | from __future__ import print_function, unicode_literals 13 | 14 | import sys 15 | import six 16 | from sievelib.commands import ( 17 | get_command_instance, IfCommand, RequireCommand, FalseCommand 18 | ) 19 | 20 | 21 | class FiltersSet(object): 22 | def __init__(self, name, filter_name_pretext="# Filter: ", filter_desc_pretext="# Description: "): 23 | """Represents a set of one or more filters 24 | 25 | :param name: the filterset's name 26 | :param filter_name_pretext: the text that is used to mark a filters name (as comment preceding the filter) 27 | :param filter_desc_pretext: the text that is used to mark a filters description 28 | """ 29 | self.name = name 30 | self.filter_name_pretext = filter_name_pretext 31 | self.filter_desc_pretext = filter_desc_pretext 32 | self.requires = [] 33 | self.filters = [] 34 | 35 | def __str__(self): 36 | target = six.StringIO() 37 | self.tosieve(target) 38 | ret = target.getvalue() 39 | target.close() 40 | return ret 41 | 42 | def __isdisabled(self, fcontent): 43 | """Tells if a filter is disabled or not 44 | 45 | Simply checks if the filter is surrounded by a "if false" test. 46 | 47 | :param fcontent: the filter's name 48 | """ 49 | if not isinstance(fcontent, IfCommand): 50 | return False 51 | if not isinstance(fcontent["test"], FalseCommand): 52 | return False 53 | return True 54 | 55 | def from_parser_result(self, parser): 56 | cpt = 1 57 | for f in parser.result: 58 | if isinstance(f, RequireCommand): 59 | if type(f.arguments["capabilities"]) == list: 60 | [self.require(c) for c in f.arguments["capabilities"]] 61 | else: 62 | self.require(f.arguments["capabilities"]) 63 | continue 64 | 65 | name = "Unnamed rule %d" % cpt 66 | description = "" 67 | for comment in f.hash_comments: 68 | if comment.startswith(self.filter_name_pretext): 69 | name = comment.replace(self.filter_name_pretext, "") 70 | if comment.startswith(self.filter_desc_pretext): 71 | description = comment.replace(self.filter_desc_pretext, "") 72 | self.filters += [{"name": name, 73 | "description": description, 74 | "content": f, 75 | "enabled": not self.__isdisabled(f)}] 76 | cpt += 1 77 | 78 | def require(self, name): 79 | """Add a new extension to the requirements list 80 | 81 | :param name: the extension's name 82 | """ 83 | name = name.strip('"') 84 | if not name in self.requires: 85 | self.requires += [name] 86 | 87 | def __gen_require_command(self): 88 | """Internal method to create a RequireCommand based on requirements 89 | 90 | Called just before this object is going to be dumped. 91 | """ 92 | if not len(self.requires): 93 | return None 94 | reqcmd = get_command_instance("require") 95 | reqcmd.check_next_arg("stringlist", self.requires) 96 | return reqcmd 97 | 98 | def __quote_if_necessary(self, value): 99 | """Add double quotes to the given string if necessary 100 | 101 | :param value: the string to check 102 | :return: the string between quotes 103 | """ 104 | if not value.startswith(('"', "'")): 105 | return '"%s"' % value 106 | return value 107 | 108 | def __build_condition(self, condition, parent, tag=None): 109 | """Translate a condition to a valid sievelib Command. 110 | 111 | :param list condition: condition's definition 112 | :param ``Command`` parent: the parent 113 | :param str tag: tag to use instead of the one included into :keyword:`condition` 114 | :rtype: Command 115 | :return: the generated command 116 | """ 117 | if tag is None: 118 | tag = condition[1] 119 | cmd = get_command_instance("header", parent) 120 | cmd.check_next_arg("tag", tag) 121 | cmd.check_next_arg("string", self.__quote_if_necessary(condition[0])) 122 | cmd.check_next_arg("string", self.__quote_if_necessary(condition[2])) 123 | return cmd 124 | 125 | def __create_filter(self, conditions, actions, matchtype="anyof"): 126 | """Create a new filter 127 | 128 | A filter is composed of: 129 | * a name 130 | * one or more conditions (tests) combined together using ``matchtype`` 131 | * one or more actions 132 | 133 | A condition must be given as a 3-uple of the form:: 134 | 135 | (test's name, operator, value) 136 | 137 | An action must be given as a 2-uple of the form:: 138 | 139 | (action's name, value) 140 | 141 | It uses the "header" test to generate the sieve syntax 142 | corresponding to the given conditions. 143 | 144 | :param conditions: the list of conditions 145 | :param actions: the list of actions 146 | :param matchtype: "anyof" or "allof" 147 | """ 148 | ifcontrol = get_command_instance("if") 149 | mtypeobj = get_command_instance(matchtype, ifcontrol) 150 | for c in conditions: 151 | if c[0].startswith("not"): 152 | negate = True 153 | cname = c[0].replace("not", "", 1) 154 | else: 155 | negate = False 156 | cname = c[0] 157 | if cname in ("true", "false"): 158 | cmd = get_command_instance(c[0], ifcontrol) 159 | elif cname == "size": 160 | cmd = get_command_instance("size", ifcontrol) 161 | cmd.check_next_arg("tag", c[1]) 162 | cmd.check_next_arg("number", c[2]) 163 | elif cname == "exists": 164 | cmd = get_command_instance("exists", ifcontrol) 165 | cmd.check_next_arg( 166 | "stringlist", 167 | "[%s]" % (",".join('"%s"' % val for val in c[1:])) 168 | ) 169 | else: 170 | if c[1].startswith(':not'): 171 | cmd = self.__build_condition(c, ifcontrol, c[1].replace("not", "", 1)) 172 | not_cmd = get_command_instance("not", ifcontrol) 173 | not_cmd.check_next_arg("test", cmd) 174 | cmd = not_cmd 175 | else: 176 | cmd = self.__build_condition(c, ifcontrol) 177 | if negate: 178 | not_cmd = get_command_instance("not", ifcontrol) 179 | not_cmd.check_next_arg("test", cmd) 180 | cmd = not_cmd 181 | mtypeobj.check_next_arg("test", cmd) 182 | ifcontrol.check_next_arg("test", mtypeobj) 183 | 184 | for actdef in actions: 185 | action = get_command_instance(actdef[0], ifcontrol, False) 186 | if action.is_extension: 187 | self.require(actdef[0]) 188 | for arg in actdef[1:]: 189 | action.check_next_arg("string", self.__quote_if_necessary(arg)) 190 | ifcontrol.addchild(action) 191 | return ifcontrol 192 | 193 | def addfilter(self, name, conditions, actions, matchtype="anyof"): 194 | """Add a new filter to this filters set 195 | 196 | :param name: the filter's name 197 | :param conditions: the list of conditions 198 | :param actions: the list of actions 199 | :param matchtype: "anyof" or "allof" 200 | """ 201 | ifcontrol = self.__create_filter(conditions, actions, matchtype) 202 | self.filters += [{"name": name, "content": ifcontrol, "enabled": True}] 203 | 204 | def updatefilter(self, oldname, newname, conditions, actions, matchtype="anyof"): 205 | """Update a specific filter 206 | 207 | Instead of removing and re-creating the filter, we update the 208 | content in order to keep the original order between filters. 209 | 210 | :param oldname: the filter's current name 211 | :param newname: the filter's new name 212 | :param conditions: the list of conditions 213 | :param actions: the list of actions 214 | :param matchtype: "anyof" or "allof" 215 | """ 216 | for f in self.filters: 217 | if f["name"] == oldname: 218 | f["name"] = newname 219 | f["content"] = \ 220 | self.__create_filter(conditions, actions, matchtype) 221 | if not f["enabled"]: 222 | return self.disablefilter(newname) 223 | return True 224 | return False 225 | 226 | def replacefilter(self, oldname, sieve_filter, newname=None, description=None): 227 | """replace a specific sieve_filter 228 | 229 | Instead of removing and re-creating the sieve_filter, we update the 230 | content in order to keep the original order between filters. 231 | 232 | :param oldname: the sieve_filter's current name 233 | :param newname: the sieve_filter's new name 234 | :param sieve_filter: the sieve_filter object as get from FiltersSet.getfilter() 235 | """ 236 | if newname is None: 237 | newname = oldname 238 | for f in self.filters: 239 | if f["name"] == oldname: 240 | f["name"] = newname 241 | f["content"] = sieve_filter 242 | if description is not None: 243 | f['description'] = description 244 | if not f["enabled"]: 245 | return self.disablefilter(newname) 246 | return True 247 | return False 248 | 249 | def getfilter(self, name): 250 | """Search for a specific filter 251 | 252 | :param name: the filter's name 253 | :return: the Command object if found, None otherwise 254 | """ 255 | for f in self.filters: 256 | if f["name"] == name: 257 | if not f["enabled"]: 258 | return f["content"].children[0] 259 | return f["content"] 260 | return None 261 | 262 | def removefilter(self, name): 263 | """Remove a specific filter 264 | 265 | :param name: the filter's name 266 | """ 267 | for f in self.filters: 268 | if f["name"] == name: 269 | self.filters.remove(f) 270 | return True 271 | return False 272 | 273 | def enablefilter(self, name): 274 | """Enable a filter 275 | 276 | Just removes the "if false" test surrouding this filter. 277 | 278 | :param name: the filter's name 279 | """ 280 | for f in self.filters: 281 | if f["name"] != name: 282 | continue 283 | if not self.__isdisabled(f["content"]): 284 | return False 285 | f["content"] = f["content"].children[0] 286 | f["enabled"] = True 287 | return True 288 | return False # raise NotFound 289 | 290 | def is_filter_disabled(self, name): 291 | """Tells if the filter is currently disabled or not 292 | 293 | :param name: the filter's name 294 | """ 295 | for f in self.filters: 296 | if f["name"] == name: 297 | return self.__isdisabled(f["content"]) 298 | return True 299 | 300 | def disablefilter(self, name): 301 | """Disable a filter 302 | 303 | Instead of commenting the filter, we just surround it with a 304 | "if false { }" test. 305 | 306 | :param name: the filter's name 307 | :return: True if filter was disabled, False otherwise 308 | """ 309 | ifcontrol = get_command_instance("if") 310 | falsecmd = get_command_instance("false", ifcontrol) 311 | ifcontrol.check_next_arg("test", falsecmd) 312 | for f in self.filters: 313 | if f["name"] != name: 314 | continue 315 | ifcontrol.addchild(f["content"]) 316 | f["content"] = ifcontrol 317 | f["enabled"] = False 318 | return True 319 | return False 320 | 321 | def movefilter(self, name, direction): 322 | """Moves the filter up or down 323 | 324 | :param name: the filter's name 325 | :param direction: string "up" or "down" 326 | """ 327 | cpt = 0 328 | for f in self.filters: 329 | if f["name"] == name: 330 | if direction == "up": 331 | if cpt == 0: 332 | return False 333 | self.filters.remove(f) 334 | self.filters.insert(cpt - 1, f) 335 | return True 336 | if cpt == len(self.filters) - 1: 337 | return False 338 | self.filters.remove(f) 339 | self.filters.insert(cpt + 1, f) 340 | return True 341 | cpt += 1 342 | return False # raise not found 343 | 344 | def dump(self): 345 | """Dump this object 346 | 347 | Available for debugging purposes 348 | """ 349 | print("Dumping filters set %s\n" % self.name) 350 | cmd = self.__gen_require_command() 351 | if cmd: 352 | print("Dumping requirements") 353 | cmd.dump() 354 | print 355 | 356 | for f in self.filters: 357 | print("Filter Name: %s" % f["name"]) 358 | print("Filter Description: %s" % f["description"]) 359 | f["content"].dump() 360 | 361 | def tosieve(self, target=sys.stdout): 362 | """Generate the sieve syntax corresponding to this filters set 363 | 364 | This method will usually be called when this filters set is 365 | done. The default is to print the sieve syntax on the standard 366 | output. You can pass an opened file pointer object if you want 367 | to write the content elsewhere. 368 | 369 | :param target: file pointer where the sieve syntax will be printed 370 | """ 371 | cmd = self.__gen_require_command() 372 | if cmd: 373 | cmd.tosieve(target=target) 374 | target.write("\n") 375 | for f in self.filters: 376 | target.write(self.filter_name_pretext + f["name"] + "\n") 377 | if "description" in f and len(f["description"]): 378 | target.write( 379 | self.filter_desc_pretext + f["description"] + "\n" 380 | ) 381 | f["content"].tosieve(target=target) 382 | 383 | 384 | if __name__ == "__main__": 385 | fs = FiltersSet("test") 386 | 387 | fs.addfilter("rule1", 388 | [("Sender", ":is", "toto@toto.com"), ], 389 | [("fileinto", "Toto"), ]) 390 | fs.tosieve() 391 | -------------------------------------------------------------------------------- /sievelib/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | """ 5 | This module provides a simple but functional parser for the SIEVE 6 | language used to filter emails. 7 | 8 | This implementation is based on RFC 5228 (http://tools.ietf.org/html/rfc5228) 9 | 10 | """ 11 | from __future__ import print_function 12 | 13 | import codecs 14 | import re 15 | import sys 16 | 17 | from sievelib.commands import ( 18 | get_command_instance, UnknownCommand, BadArgument, BadValue 19 | ) 20 | 21 | 22 | class ParseError(Exception): 23 | """Generic parsing error""" 24 | 25 | def __init__(self, msg): 26 | self.msg = msg 27 | 28 | def __str__(self): 29 | return "parsing error: %s" % self.msg 30 | 31 | 32 | class Lexer(object): 33 | """ 34 | The lexical analysis part. 35 | 36 | This class provides a simple way to define tokens (with patterns) 37 | to be detected. 38 | 39 | Patterns are provided into a list of 2-uple. Each 2-uple consists 40 | of a token name and an associated pattern, example: 41 | 42 | [("left_bracket", r'\['),] 43 | """ 44 | 45 | def __init__(self, definitions): 46 | self.definitions = definitions 47 | parts = [] 48 | for name, part in definitions: 49 | parts.append("(?P<%s>%s)" % (name, part)) 50 | self.regexpString = "|".join(parts) 51 | self.regexp = re.compile(self.regexpString, re.MULTILINE) 52 | self.wsregexp = re.compile(r'\s+', re.M) 53 | 54 | def curlineno(self): 55 | """Return the current line number""" 56 | return self.text[:self.pos].count('\n') + 1 57 | 58 | def scan(self, text): 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 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 | raise ParseError("unknown token %s" % text[self.pos:]) 80 | 81 | self.pos = m.end() 82 | yield (m.lastgroup, m.group(m.lastgroup)) 83 | 84 | 85 | class Parser(object): 86 | """The grammatical analysis part. 87 | 88 | Here we define the SIEVE language tokens and grammar. This class 89 | works with a Lexer object in order to check for grammar validity. 90 | """ 91 | lrules = [ 92 | ("left_bracket", r'\['), 93 | ("right_bracket", r'\]'), 94 | ("left_parenthesis", r'\('), 95 | ("right_parenthesis", r'\)'), 96 | ("left_cbracket", r'{'), 97 | ("right_cbracket", r'}'), 98 | ("semicolon", r';'), 99 | ("comma", r','), 100 | ("hash_comment", r'#.*$'), 101 | ("bracket_comment", r'/\*[\s\S]*?\*/'), 102 | ("multiline", r'text:[^$]*?[\r\n]+\.$'), 103 | ("string", r'"([^"\\]|\\.)*"'), 104 | ("identifier", r'[a-zA-Z_][\w]*'), 105 | ("tag", r':[a-zA-Z_][\w]*'), 106 | ("number", r'[0-9]+[KMGkmg]?'), 107 | ] 108 | 109 | def __init__(self, debug=False): 110 | self.debug = debug 111 | self.lexer = Lexer(Parser.lrules) 112 | 113 | def __dprint(self, *msgs): 114 | if not self.debug: 115 | return 116 | for m in msgs: 117 | print(m) 118 | 119 | def __reset_parser(self): 120 | """Reset parser's internal variables 121 | 122 | Restore the parser to an initial state. Useful when creating a 123 | new parser or reusing an existing one. 124 | """ 125 | self.result = [] 126 | self.hash_comments = [] 127 | 128 | self.__cstate = None 129 | self.__curcommand = None 130 | self.__curstringlist = None 131 | self.__expected = None 132 | self.__opened_blocks = 0 133 | 134 | def __set_expected(self, *args, **kwargs): 135 | """Set the next expected token. 136 | 137 | One or more tokens can be provided. (they will represent the 138 | valid possibilities for the next token). 139 | """ 140 | self.__expected = args 141 | 142 | def __up(self, onlyrecord=False): 143 | """Return to the current command's parent 144 | 145 | This method should be called each time a command is 146 | complete. In case of a top level command (no parent), it is 147 | recorded into a specific list for further usage. 148 | 149 | :param onlyrecord: tell to only record the new command into its parent. 150 | """ 151 | if self.__curcommand.must_follow is not None: 152 | if not self.__curcommand.parent: 153 | prevcmd = self.result[-1] if len(self.result) else None 154 | else: 155 | prevcmd = self.__curcommand.parent.children[-2] \ 156 | if len(self.__curcommand.parent.children) >= 2 else None 157 | if prevcmd is None or prevcmd.name not in self.__curcommand.must_follow: 158 | raise ParseError("the %s command must follow an %s command" % 159 | (self.__curcommand.name, 160 | " or ".join(self.__curcommand.must_follow))) 161 | 162 | if not self.__curcommand.parent: 163 | # collect current amount of hash comments for later 164 | # parsing into names and desciptions 165 | self.__curcommand.hash_comments = self.hash_comments 166 | self.hash_comments = [] 167 | self.result += [self.__curcommand] 168 | 169 | if not onlyrecord: 170 | self.__curcommand = self.__curcommand.parent 171 | 172 | def __check_command_completion(self, testsemicolon=True): 173 | """Check for command(s) completion 174 | 175 | This function should be called each time a new argument is 176 | seen by the parser in order to check a command is complete. As 177 | not only one command can be ended when receiving a new 178 | argument (nested commands case), we apply the same work to 179 | parent commands. 180 | 181 | :param testsemicolon: if True, indicates that the next 182 | expected token must be a semicolon (for commands that need one) 183 | :return: True if command is 184 | considered as complete, False otherwise. 185 | """ 186 | if not self.__curcommand.iscomplete(): 187 | return True 188 | 189 | ctype = self.__curcommand.get_type() 190 | if ctype == "action" or \ 191 | (ctype == "control" and 192 | not self.__curcommand.accept_children): 193 | if testsemicolon: 194 | self.__set_expected("semicolon") 195 | return True 196 | 197 | while self.__curcommand.parent: 198 | cmd = self.__curcommand 199 | self.__curcommand = self.__curcommand.parent 200 | if self.__curcommand.get_type() in ["control", "test"]: 201 | if self.__curcommand.iscomplete(): 202 | if self.__curcommand.get_type() == "control": 203 | break 204 | continue 205 | if not self.__curcommand.check_next_arg("test", cmd, add=False): 206 | return False 207 | if not self.__curcommand.iscomplete(): 208 | if self.__curcommand.variable_args_nb: 209 | self.__set_expected("comma", "right_parenthesis") 210 | break 211 | 212 | return True 213 | 214 | def __stringlist(self, ttype, tvalue): 215 | """Specific method to parse the 'string-list' type 216 | 217 | Syntax: 218 | string-list = "[" string *("," string) "]" / string 219 | ; if there is only a single string, the brackets 220 | ; are optional 221 | """ 222 | if ttype == "string": 223 | self.__curstringlist += [tvalue] 224 | self.__set_expected("comma", "right_bracket") 225 | return True 226 | if ttype == "comma": 227 | self.__set_expected("string") 228 | return True 229 | if ttype == "right_bracket": 230 | self.__curcommand.check_next_arg("stringlist", self.__curstringlist) 231 | self.__cstate = self.__arguments 232 | return self.__check_command_completion() 233 | return False 234 | 235 | def __argument(self, ttype, tvalue): 236 | """Argument parsing method 237 | 238 | This method acts as an entry point for 'argument' parsing. 239 | 240 | Syntax: 241 | string-list / number / tag 242 | 243 | :param ttype: current token type 244 | :param tvalue: current token value 245 | :return: False if an error is encountered, True otherwise 246 | """ 247 | if ttype == "multiline": 248 | return self.__curcommand.check_next_arg("string", tvalue) 249 | 250 | if ttype in ["number", "tag", "string"]: 251 | return self.__curcommand.check_next_arg(ttype, tvalue) 252 | 253 | if ttype == "left_bracket": 254 | self.__cstate = self.__stringlist 255 | self.__curstringlist = [] 256 | self.__set_expected("string") 257 | return True 258 | return False 259 | 260 | def __arguments(self, ttype, tvalue): 261 | """Arguments parsing method 262 | 263 | Entry point for command arguments parsing. The parser must 264 | call this method for each parsed command (either a control, 265 | action or test). 266 | 267 | Syntax: 268 | *argument [ test / test-list ] 269 | 270 | :param ttype: current token type 271 | :param tvalue: current token value 272 | :return: False if an error is encountered, True otherwise 273 | """ 274 | if ttype == "identifier": 275 | test = get_command_instance(tvalue, self.__curcommand) 276 | self.__curcommand.check_next_arg("test", test) 277 | self.__expected = test.get_expected_first() 278 | self.__curcommand = test 279 | return self.__check_command_completion(testsemicolon=False) 280 | 281 | if ttype == "left_parenthesis": 282 | self.__set_expected("identifier") 283 | return True 284 | 285 | if ttype == "comma": 286 | self.__set_expected("identifier") 287 | return True 288 | 289 | if ttype == "right_parenthesis": 290 | self.__up() 291 | return True 292 | 293 | if self.__argument(ttype, tvalue): 294 | return self.__check_command_completion(testsemicolon=False) 295 | 296 | return False 297 | 298 | def __command(self, ttype, tvalue): 299 | """Command parsing method 300 | 301 | Entry point for command parsing. Here is expected behaviour: 302 | * Handle command beginning if detected, 303 | * Call the appropriate sub-method (specified by __cstate) to 304 | handle the body, 305 | * Handle command ending or block opening if detected. 306 | 307 | Syntax: 308 | identifier arguments (";" / block) 309 | 310 | :param ttype: current token type 311 | :param tvalue: current token value 312 | :return: False if an error is encountered, True otherwise 313 | """ 314 | if self.__cstate is None: 315 | if ttype == "right_cbracket": 316 | self.__up() 317 | self.__opened_blocks -= 1 318 | self.__cstate = None 319 | return True 320 | 321 | if ttype != "identifier": 322 | return False 323 | command = get_command_instance(tvalue, self.__curcommand) 324 | if command.get_type() == "test": 325 | raise ParseError("%s may not appear as a first command" % command.name) 326 | if command.get_type() == "control" and command.accept_children \ 327 | and command.has_arguments(): 328 | self.__set_expected("identifier") 329 | if self.__curcommand is not None: 330 | if not self.__curcommand.addchild(command): 331 | raise ParseError("%s unexpected after a %s" % \ 332 | (tvalue, self.__curcommand.name)) 333 | self.__curcommand = command 334 | self.__cstate = self.__arguments 335 | 336 | return True 337 | 338 | if self.__cstate(ttype, tvalue): 339 | return True 340 | 341 | if ttype == "left_cbracket": 342 | self.__opened_blocks += 1 343 | self.__cstate = None 344 | return True 345 | 346 | if ttype == "semicolon": 347 | self.__cstate = None 348 | if not self.__check_command_completion(testsemicolon=False): 349 | return False 350 | self.__curcommand.complete_cb() 351 | self.__up() 352 | return True 353 | return False 354 | 355 | def parse(self, text): 356 | """The parser entry point. 357 | 358 | Parse the provided text to check for its validity. 359 | 360 | On success, the parsing tree is available into the result 361 | attribute. It is a list of sievecommands.Command objects (see 362 | the module documentation for specific information). 363 | 364 | On error, an string containing the explicit reason is 365 | available into the error attribute. 366 | 367 | :param text: a string containing the data to parse 368 | :return: True on success (no error detected), False otherwise 369 | """ 370 | self.__reset_parser() 371 | try: 372 | for ttype, tvalue in self.lexer.scan(text): 373 | if ttype == "hash_comment": 374 | self.hash_comments += [tvalue.strip()] 375 | continue 376 | if ttype == "bracket_comment": 377 | continue 378 | if self.__expected is not None: 379 | if not ttype in self.__expected: 380 | if self.lexer.pos < len(text): 381 | msg = "%s found while %s expected near '%s'" \ 382 | % (ttype, "|".join(self.__expected), text[self.lexer.pos]) 383 | else: 384 | msg = "%s found while %s expected at end of file" \ 385 | % (ttype, "|".join(self.__expected)) 386 | raise ParseError(msg) 387 | self.__expected = None 388 | 389 | if not self.__command(ttype, tvalue): 390 | msg = "unexpected token '%s' found near '%s'" \ 391 | % (tvalue, text[self.lexer.pos]) 392 | raise ParseError(msg) 393 | if self.__opened_blocks: 394 | self.__set_expected("right_cbracket") 395 | if self.__expected is not None: 396 | raise ParseError("end of script reached while %s expected" % 397 | "|".join(self.__expected)) 398 | 399 | except (ParseError, UnknownCommand, BadArgument, BadValue) as e: 400 | self.error = "line %d: %s" % (self.lexer.curlineno(), str(e)) 401 | return False 402 | return True 403 | 404 | def parse_file(self, name): 405 | """Parse the content of a file. 406 | 407 | See 'parse' method for information. 408 | 409 | :param name: the pathname of the file to parse 410 | :return: True on success (no error detected), False otherwise 411 | """ 412 | fp = codecs.open(name, encoding='utf8') 413 | content = fp.read() 414 | fp.close() 415 | return self.parse(content) 416 | 417 | def dump(self, target=sys.stdout): 418 | """Dump the parsing tree. 419 | 420 | This method displays the parsing tree on the standard output. 421 | """ 422 | for r in self.result: 423 | r.dump(target=target) 424 | 425 | 426 | if __name__ == "__main__": 427 | from optparse import OptionParser 428 | 429 | op = OptionParser() 430 | op.usage = "%prog: [options] files" 431 | op.add_option("-v", "--verbose", action="store_true", default=False, 432 | help="Activate verbose mode") 433 | op.add_option("-d", "--debug", action="store_true", default=False, 434 | help="Activate debug traces") 435 | op.add_option("--tosieve", action="store_true", 436 | help="Print parser results using sieve") 437 | options, args = op.parse_args() 438 | 439 | if not len(args): 440 | print("Nothing to parse, exiting.") 441 | sys.exit(0) 442 | 443 | for a in args: 444 | p = Parser(debug=options.debug) 445 | print("Parsing file %s... " % a, end=' ') 446 | if p.parse_file(a): 447 | print("OK") 448 | if options.verbose: 449 | p.dump() 450 | if options.tosieve: 451 | for r in p.result: 452 | r.tosieve() 453 | continue 454 | print("ERROR") 455 | print(p.error) 456 | -------------------------------------------------------------------------------- /sievelib/tests/test_parser.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | Unit tests for the SIEVE language parser. 5 | """ 6 | import unittest 7 | import os.path 8 | import codecs 9 | import six 10 | 11 | from sievelib.parser import Parser 12 | from sievelib.factory import FiltersSet 13 | import sievelib.commands 14 | 15 | 16 | class MytestCommand(sievelib.commands.ActionCommand): 17 | args_definition = [ 18 | {"name": "testtag", 19 | "type": ["tag"], 20 | "write_tag": True, 21 | "values": [":testtag"], 22 | "extra_arg": {"type": "number", 23 | "required": False}, 24 | "required": False}, 25 | {"name": "recipients", 26 | "type": ["string", "stringlist"], 27 | "required": True} 28 | ] 29 | 30 | 31 | class Quota_notificationCommand(sievelib.commands.ActionCommand): 32 | args_definition = [ 33 | {"name": "subject", 34 | "type": ["tag"], 35 | "write_tag": True, 36 | "values": [":subject"], 37 | "extra_arg": {"type": "string"}, 38 | "required": False}, 39 | {"name": "recipient", 40 | "type": ["tag"], 41 | "write_tag": True, 42 | "values": [":recipient"], 43 | "extra_arg": {"type": "stringlist"}, 44 | "required": True} 45 | ] 46 | 47 | 48 | class SieveTest(unittest.TestCase): 49 | def setUp(self): 50 | self.parser = Parser() 51 | 52 | def __checkCompilation(self, script, result): 53 | self.assertEqual(self.parser.parse(script), result) 54 | 55 | def compilation_ok(self, script): 56 | self.__checkCompilation(script, True) 57 | 58 | def compilation_ko(self, script): 59 | self.__checkCompilation(script, False) 60 | 61 | def representation_is(self, content): 62 | target = six.StringIO() 63 | self.parser.dump(target) 64 | repr_ = target.getvalue() 65 | target.close() 66 | self.assertEqual(repr_, content.lstrip()) 67 | 68 | def sieve_is(self, content): 69 | filtersset = FiltersSet("Testfilterset") 70 | filtersset.from_parser_result(self.parser) 71 | target = six.StringIO() 72 | filtersset.tosieve(target) 73 | repr_ = target.getvalue() 74 | target.close() 75 | self.assertEqual(repr_, content) 76 | 77 | 78 | class AdditionalCommands(SieveTest): 79 | def test_add_command(self): 80 | self.assertRaises( 81 | sievelib.commands.UnknownCommand, 82 | sievelib.commands.get_command_instance, 'mytest' 83 | ) 84 | sievelib.commands.add_commands(MytestCommand) 85 | sievelib.commands.get_command_instance('mytest') 86 | self.compilation_ok(""" 87 | mytest :testtag 10 ["testrecp1@example.com"]; 88 | """) 89 | 90 | def test_quota_notification(self): 91 | sievelib.commands.add_commands(Quota_notificationCommand) 92 | quota_notification_sieve = """# Filter: Testrule\nquota_notification :subject "subject here" :recipient ["somerecipient@example.com"];\n""" 93 | self.compilation_ok(quota_notification_sieve) 94 | self.sieve_is(quota_notification_sieve) 95 | 96 | 97 | class ValidEncodings(SieveTest): 98 | def test_utf8_file(self): 99 | utf8_sieve = os.path.join( 100 | os.path.dirname(__file__), 'files', 'utf8_sieve.txt' 101 | ) 102 | with codecs.open(utf8_sieve, encoding='utf8') as fobj: 103 | source_sieve = fobj.read() 104 | self.parser.parse_file(utf8_sieve) 105 | self.sieve_is(source_sieve) 106 | 107 | 108 | class ValidSyntaxes(SieveTest): 109 | def test_hash_comment(self): 110 | self.compilation_ok(""" 111 | if size :over 100k { # this is a comment 112 | discard; 113 | } 114 | """) 115 | self.representation_is(""" 116 | if (type: control) 117 | size (type: test) 118 | :over 119 | 100k 120 | discard (type: action) 121 | """) 122 | 123 | def test_bracket_comment(self): 124 | self.compilation_ok(""" 125 | if size :over 100K { /* this is a comment 126 | this is still a comment */ discard /* this is a comment 127 | */ ; 128 | } 129 | """) 130 | self.representation_is(""" 131 | if (type: control) 132 | size (type: test) 133 | :over 134 | 100K 135 | discard (type: action) 136 | """) 137 | 138 | def test_string_with_bracket_comment(self): 139 | self.compilation_ok(""" 140 | if header :contains "Cc" "/* comment */" { 141 | discard; 142 | } 143 | """) 144 | self.representation_is(""" 145 | if (type: control) 146 | header (type: test) 147 | :contains 148 | "Cc" 149 | "/* comment */" 150 | discard (type: action) 151 | """) 152 | 153 | def test_multiline_string(self): 154 | self.compilation_ok(""" 155 | require "reject"; 156 | 157 | if allof (false, address :is ["From", "Sender"] ["blka@bla.com"]) { 158 | reject text: 159 | noreply 160 | ============================ 161 | Your email has been canceled 162 | ============================ 163 | . 164 | ; 165 | stop; 166 | } else { 167 | reject text: 168 | ================================ 169 | Your email has been canceled too 170 | ================================ 171 | . 172 | ; 173 | } 174 | """) 175 | self.representation_is(""" 176 | require (type: control) 177 | "reject" 178 | if (type: control) 179 | allof (type: test) 180 | false (type: test) 181 | address (type: test) 182 | :is 183 | ["From","Sender"] 184 | ["blka@bla.com"] 185 | reject (type: action) 186 | text: 187 | noreply 188 | ============================ 189 | Your email has been canceled 190 | ============================ 191 | . 192 | stop (type: control) 193 | else (type: control) 194 | reject (type: action) 195 | text: 196 | ================================ 197 | Your email has been canceled too 198 | ================================ 199 | . 200 | """) 201 | 202 | def test_nested_blocks(self): 203 | self.compilation_ok(""" 204 | if header :contains "Sender" "example.com" { 205 | if header :contains "Sender" "me@" { 206 | discard; 207 | } elsif header :contains "Sender" "you@" { 208 | keep; 209 | } 210 | } 211 | """) 212 | self.representation_is(""" 213 | if (type: control) 214 | header (type: test) 215 | :contains 216 | "Sender" 217 | "example.com" 218 | if (type: control) 219 | header (type: test) 220 | :contains 221 | "Sender" 222 | "me@" 223 | discard (type: action) 224 | elsif (type: control) 225 | header (type: test) 226 | :contains 227 | "Sender" 228 | "you@" 229 | keep (type: action) 230 | """) 231 | 232 | def test_true_test(self): 233 | self.compilation_ok(""" 234 | if true { 235 | 236 | } 237 | """) 238 | self.representation_is(""" 239 | if (type: control) 240 | true (type: test) 241 | """) 242 | 243 | def test_rfc5228_extended(self): 244 | self.compilation_ok(""" 245 | # 246 | # Example Sieve Filter 247 | # Declare any optional features or extension used by the script 248 | # 249 | require ["fileinto"]; 250 | 251 | # 252 | # Handle messages from known mailing lists 253 | # Move messages from IETF filter discussion list to filter mailbox 254 | # 255 | if header :is "Sender" "owner-ietf-mta-filters@imc.org" 256 | { 257 | fileinto "filter"; # move to "filter" mailbox 258 | } 259 | # 260 | # Keep all messages to or from people in my company 261 | # 262 | elsif address :DOMAIN :is ["From", "To"] "example.com" 263 | { 264 | keep; # keep in "In" mailbox 265 | } 266 | 267 | # 268 | # Try and catch unsolicited email. If a message is not to me, 269 | # or it contains a subject known to be spam, file it away. 270 | # 271 | elsif anyof (NOT address :all :contains 272 | ["To", "Cc", "Bcc"] "me@example.com", 273 | header :matches "subject" 274 | ["*make*money*fast*", "*university*dipl*mas*"]) 275 | { 276 | fileinto "spam"; # move to "spam" mailbox 277 | } 278 | else 279 | { 280 | # Move all other (non-company) mail to "personal" 281 | # mailbox. 282 | fileinto "personal"; 283 | } 284 | """) 285 | self.representation_is(""" 286 | require (type: control) 287 | ["fileinto"] 288 | if (type: control) 289 | header (type: test) 290 | :is 291 | "Sender" 292 | "owner-ietf-mta-filters@imc.org" 293 | fileinto (type: action) 294 | "filter" 295 | elsif (type: control) 296 | address (type: test) 297 | :DOMAIN 298 | :is 299 | ["From","To"] 300 | "example.com" 301 | keep (type: action) 302 | elsif (type: control) 303 | anyof (type: test) 304 | not (type: test) 305 | address (type: test) 306 | :all 307 | :contains 308 | ["To","Cc","Bcc"] 309 | "me@example.com" 310 | header (type: test) 311 | :matches 312 | "subject" 313 | ["*make*money*fast*","*university*dipl*mas*"] 314 | fileinto (type: action) 315 | "spam" 316 | else (type: control) 317 | fileinto (type: action) 318 | "personal" 319 | """) 320 | 321 | def test_explicit_comparator(self): 322 | self.compilation_ok(""" 323 | if header :contains :comparator "i;octet" "Subject" "MAKE MONEY FAST" { 324 | discard; 325 | } 326 | """) 327 | self.representation_is(""" 328 | if (type: control) 329 | header (type: test) 330 | "i;octet" 331 | :contains 332 | "Subject" 333 | "MAKE MONEY FAST" 334 | discard (type: action) 335 | """) 336 | 337 | def test_non_ordered_args(self): 338 | self.compilation_ok(""" 339 | if address :all :is "from" "tim@example.com" { 340 | discard; 341 | } 342 | """) 343 | self.representation_is(""" 344 | if (type: control) 345 | address (type: test) 346 | :all 347 | :is 348 | "from" 349 | "tim@example.com" 350 | discard (type: action) 351 | """) 352 | 353 | def test_multiple_not(self): 354 | self.compilation_ok(""" 355 | if not not not not true { 356 | stop; 357 | } 358 | """) 359 | self.representation_is(""" 360 | if (type: control) 361 | not (type: test) 362 | not (type: test) 363 | not (type: test) 364 | not (type: test) 365 | true (type: test) 366 | stop (type: control) 367 | """) 368 | 369 | def test_just_one_command(self): 370 | self.compilation_ok("keep;") 371 | self.representation_is(""" 372 | keep (type: action) 373 | """) 374 | 375 | def test_singletest_testlist(self): 376 | self.compilation_ok(""" 377 | if anyof (true) { 378 | discard; 379 | } 380 | """) 381 | self.representation_is(""" 382 | if (type: control) 383 | anyof (type: test) 384 | true (type: test) 385 | discard (type: action) 386 | """) 387 | 388 | def test_truefalse_testlist(self): 389 | self.compilation_ok(""" 390 | if anyof(true, false) { 391 | discard; 392 | } 393 | """) 394 | self.representation_is(""" 395 | if (type: control) 396 | anyof (type: test) 397 | true (type: test) 398 | false (type: test) 399 | discard (type: action) 400 | """) 401 | 402 | def test_vacationext_basic(self): 403 | self.compilation_ok(""" 404 | require "vacation"; 405 | if header :contains "subject" "cyrus" { 406 | vacation "I'm out -- send mail to cyrus-bugs"; 407 | } else { 408 | vacation "I'm out -- call me at +1 304 555 0123"; 409 | } 410 | """) 411 | 412 | def test_vacationext_medium(self): 413 | self.compilation_ok(""" 414 | require "vacation"; 415 | if header :contains "subject" "lunch" { 416 | vacation :handle "ran-away" "I'm out and can't meet for lunch"; 417 | } else { 418 | vacation :handle "ran-away" "I'm out"; 419 | } 420 | """) 421 | 422 | def test_vacationext_with_limit(self): 423 | self.compilation_ok(""" 424 | require "vacation"; 425 | vacation :days 23 :addresses ["tjs@example.edu", 426 | "ts4z@landru.example.edu"] 427 | "I'm away until October 19. 428 | If it's an emergency, call 911, I guess." ; 429 | """) 430 | 431 | def test_vacationext_with_multiline(self): 432 | self.compilation_ok(""" 433 | require "vacation"; 434 | vacation :mime text: 435 | Content-Type: multipart/alternative; boundary=foo 436 | 437 | --foo 438 | 439 | I'm at the beach relaxing. Mmmm, surf... 440 | 441 | --foo 442 | Content-Type: text/html; charset=us-ascii 443 | 444 | 446 | How to relax 447 | 448 |

I'm at the beach relaxing. 449 | Mmmm, surf... 450 | 451 | 452 | --foo-- 453 | . 454 | ; 455 | """) 456 | 457 | def test_reject_extension(self): 458 | self.compilation_ok(""" 459 | require "reject"; 460 | 461 | if header :contains "subject" "viagra" { 462 | reject; 463 | } 464 | """) 465 | 466 | 467 | class InvalidSyntaxes(SieveTest): 468 | def test_nested_comments(self): 469 | self.compilation_ko(""" 470 | /* this is a comment /* with a nested comment inside */ 471 | it is allowed by the RFC :p */ 472 | """) 473 | 474 | def test_nonopened_block(self): 475 | self.compilation_ko(""" 476 | if header :is "Sender" "me@example.com" 477 | discard; 478 | } 479 | """) 480 | 481 | def test_nonclosed_block(self): 482 | self.compilation_ko(""" 483 | if header :is "Sender" "me@example.com" { 484 | discard; 485 | 486 | """) 487 | 488 | def test_unknown_token(self): 489 | self.compilation_ko(""" 490 | if header :is "Sender" "Toto" & header :contains "Cc" "Tata" { 491 | 492 | } 493 | """) 494 | 495 | def test_empty_string_list(self): 496 | self.compilation_ko("require [];") 497 | 498 | def test_unclosed_string_list(self): 499 | self.compilation_ko('require ["toto", "tata";') 500 | 501 | def test_misplaced_comma_in_string_list(self): 502 | self.compilation_ko('require ["toto",];') 503 | 504 | def test_nonopened_tests_list(self): 505 | self.compilation_ko(""" 506 | if anyof header :is "Sender" "me@example.com", 507 | header :is "Sender" "myself@example.com") { 508 | fileinto "trash"; 509 | } 510 | """) 511 | 512 | def test_nonclosed_tests_list(self): 513 | self.compilation_ko(""" 514 | if anyof (header :is "Sender" "me@example.com", 515 | header :is "Sender" "myself@example.com" { 516 | fileinto "trash"; 517 | } 518 | """) 519 | 520 | def test_nonclosed_tests_list2(self): 521 | self.compilation_ko(""" 522 | if anyof (header :is "Sender" { 523 | fileinto "trash"; 524 | } 525 | """) 526 | 527 | def test_misplaced_comma_in_tests_list(self): 528 | self.compilation_ko(""" 529 | if anyof (header :is "Sender" "me@example.com",) { 530 | 531 | } 532 | """) 533 | 534 | def test_comma_inside_arguments(self): 535 | self.compilation_ko(""" 536 | require "fileinto", "enveloppe"; 537 | """) 538 | 539 | def test_non_ordered_args(self): 540 | self.compilation_ko(""" 541 | if address "From" :is "tim@example.com" { 542 | discard; 543 | } 544 | """) 545 | 546 | def test_extra_arg(self): 547 | self.compilation_ko(""" 548 | if address :is "From" "tim@example.com" "tutu" { 549 | discard; 550 | } 551 | """) 552 | 553 | def test_empty_not(self): 554 | self.compilation_ko(""" 555 | if not { 556 | discard; 557 | } 558 | """) 559 | 560 | def test_missing_semicolon(self): 561 | self.compilation_ko(""" 562 | require ["fileinto"] 563 | """) 564 | 565 | def test_missing_semicolon_in_block(self): 566 | self.compilation_ko(""" 567 | if true { 568 | stop 569 | } 570 | """) 571 | 572 | def test_misplaced_parenthesis(self): 573 | self.compilation_ko(""" 574 | if (true) { 575 | 576 | } 577 | """) 578 | 579 | 580 | class LanguageRestrictions(SieveTest): 581 | def test_unknown_control(self): 582 | self.compilation_ko(""" 583 | macommande "Toto"; 584 | """) 585 | 586 | def test_misplaced_elsif(self): 587 | self.compilation_ko(""" 588 | elsif true { 589 | 590 | } 591 | """) 592 | 593 | def test_misplaced_elsif2(self): 594 | self.compilation_ko(""" 595 | elsif header :is "From" "toto" { 596 | 597 | } 598 | """) 599 | 600 | def test_misplaced_nested_elsif(self): 601 | self.compilation_ko(""" 602 | if true { 603 | elsif false { 604 | 605 | } 606 | } 607 | """) 608 | 609 | def test_unexpected_argument(self): 610 | self.compilation_ko('stop "toto";') 611 | 612 | def test_bad_arg_value(self): 613 | self.compilation_ko(""" 614 | if header :isnot "Sent" "me@example.com" { 615 | stop; 616 | } 617 | """) 618 | 619 | def test_bad_arg_value2(self): 620 | self.compilation_ko(""" 621 | if header :isnot "Sent" 10000 { 622 | stop; 623 | } 624 | """) 625 | 626 | def test_bad_comparator_value(self): 627 | self.compilation_ko(""" 628 | if header :contains :comparator "i;prout" "Subject" "MAKE MONEY FAST" { 629 | discard; 630 | } 631 | """) 632 | 633 | def test_not_included_extension(self): 634 | self.compilation_ko(""" 635 | if header :contains "Subject" "MAKE MONEY FAST" { 636 | fileinto "spam"; 637 | } 638 | """) 639 | 640 | def test_test_outside_control(self): 641 | self.compilation_ko("true;") 642 | 643 | 644 | class DateCommands(SieveTest): 645 | def test_currentdate_command(self): 646 | self.compilation_ok("""require ["date", "relational"]; 647 | 648 | if allof ( currentdate :value "ge" "date" "2013-10-23" , currentdate :value "le" "date" "2014-10-12" ) 649 | { 650 | discard; 651 | } 652 | """) 653 | 654 | def test_currentdate_command_timezone(self): 655 | self.compilation_ok("""require ["date", "relational"]; 656 | 657 | if allof ( currentdate :zone "+0100" :value "ge" "date" "2013-10-23" , currentdate :value "le" "date" "2014-10-12" ) 658 | { 659 | discard; 660 | } 661 | """) 662 | 663 | def test_currentdate_norel(self): 664 | self.compilation_ok("""require ["date"]; 665 | 666 | if allof ( 667 | currentdate :zone "+0100" :is "date" "2013-10-23" 668 | ) 669 | { 670 | discard; 671 | }""") 672 | 673 | 674 | class VariablesCommands(SieveTest): 675 | def test_set_command(self): 676 | self.compilation_ok("""require ["variables"]; 677 | 678 | set "matchsub" "testsubject"; 679 | 680 | if allof ( 681 | header :contains ["Subject"] "${header}" 682 | ) 683 | { 684 | discard; 685 | } 686 | """) 687 | 688 | 689 | if __name__ == "__main__": 690 | unittest.main() 691 | 692 | -------------------------------------------------------------------------------- /sievelib/managesieve.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | """ 5 | A MANAGESIEVE client 6 | 7 | A protocol for securely managing Sieve scripts on a remote server. 8 | This protocol allows a user to have multiple scripts, and also alerts 9 | a user to syntactically flawed scripts. 10 | 11 | Implementation based on . 12 | """ 13 | from __future__ import print_function 14 | 15 | import socket 16 | import re 17 | import base64 18 | import ssl 19 | 20 | import six 21 | 22 | from digest_md5 import DigestMD5 23 | 24 | CRLF = '\r\n' 25 | 26 | KNOWN_CAPABILITIES = ["IMPLEMENTATION", "SASL", "SIEVE", 27 | "STARTTLS", "NOTIFY", "LANGUAGE", 28 | "RENAME"] 29 | 30 | SUPPORTED_AUTH_MECHS = ["DIGEST-MD5", "PLAIN", "LOGIN"] 31 | 32 | 33 | class Error(Exception): 34 | pass 35 | 36 | 37 | class Response(Exception): 38 | def __init__(self, code, data): 39 | self.code = code 40 | self.data = data 41 | 42 | def __str__(self): 43 | return "%s %s" % (self.code, self.data) 44 | 45 | 46 | class Literal(Exception): 47 | def __init__(self, value): 48 | self.value = value 49 | 50 | def __str__(self): 51 | return "{%d}" % self.value 52 | 53 | 54 | def authentication_required(meth): 55 | """Simple class method decorator. 56 | 57 | Checks if the client is currently connected. 58 | 59 | :param meth: the original called method 60 | """ 61 | 62 | def check(cls, *args, **kwargs): 63 | if cls.authenticated: 64 | return meth(cls, *args, **kwargs) 65 | raise Error("Authentication required") 66 | 67 | return check 68 | 69 | 70 | class Client(object): 71 | read_size = 4096 72 | read_timeout = 5 73 | 74 | def __init__(self, srvaddr, srvport=4190, debug=False): 75 | self.srvaddr = srvaddr 76 | self.srvport = srvport 77 | self.__debug = debug 78 | self.sock = None 79 | self.__read_buffer = "" 80 | self.authenticated = False 81 | self.errcode = None 82 | 83 | self.__capabilities = {} 84 | self.__respcode_expr = re.compile(r"(OK|NO|BYE)\s*(.+)?") 85 | self.__error_expr = re.compile(r'(\(\w+\))?\s*(".+")') 86 | self.__size_expr = re.compile(r"\{(\d+)\+?\}") 87 | self.__active_expr = re.compile("ACTIVE", re.IGNORECASE) 88 | 89 | def __del__(self): 90 | if self.sock is not None: 91 | self.sock.close() 92 | self.sock = None 93 | 94 | def __dprint(self, message): 95 | if not self.__debug: 96 | return 97 | print("DEBUG: %s" % message) 98 | 99 | def __read_block(self, size): 100 | """Read a block of 'size' bytes from the server. 101 | 102 | An internal buffer is used to read data from the server. If 103 | enough data is available from it, we return that data. 104 | 105 | Eventually, we try to grab the missing part from the server 106 | for Client.read_timeout seconds. If no data can be 107 | retrieved, it is considered as a fatal error and an 'Error' 108 | exception is raised. 109 | 110 | :param size: number of bytes to read 111 | :rtype: string 112 | :returns: the read block (can be empty) 113 | """ 114 | buf = "" 115 | if len(self.__read_buffer): 116 | limit = size if size <= len(self.__read_buffer) else len(self.__read_buffer) 117 | buf = self.__read_buffer[:limit] 118 | self.__read_buffer = self.__read_buffer[limit:] 119 | size -= limit 120 | if not size: 121 | return buf 122 | try: 123 | buf += self.sock.recv(size) 124 | except (socket.timeout, ssl.SSLError) as e: 125 | raise Error("Failed to read %d bytes from the server" % size) 126 | return buf 127 | 128 | def __read_line(self): 129 | """Read one line from the server. 130 | 131 | An internal buffer is used to read data from the server 132 | (blocks of Client.read_size bytes). If the buffer 133 | is not empty, we try to find an entire line to return. 134 | 135 | If we failed, we try to read new content from the server for 136 | Client.read_timeout seconds. If no data can be 137 | retrieved, it is considered as a fatal error and an 'Error' 138 | exception is raised. 139 | 140 | :rtype: string 141 | :return: the read line 142 | """ 143 | ret = "" 144 | while True: 145 | try: 146 | pos = self.__read_buffer.index(CRLF) 147 | ret = self.__read_buffer[0:pos] 148 | self.__read_buffer = self.__read_buffer[pos + len(CRLF):] 149 | break 150 | except ValueError: 151 | pass 152 | try: 153 | nval = self.sock.recv(self.read_size) 154 | if not len(nval): 155 | break 156 | self.__read_buffer += nval 157 | except (socket.timeout, ssl.SSLError): 158 | raise Error("Failed to read data from the server") 159 | 160 | if len(ret): 161 | m = self.__size_expr.match(ret) 162 | if m: 163 | raise Literal(int(m.group(1))) 164 | 165 | m = self.__respcode_expr.match(ret) 166 | if m: 167 | if m.group(1) == "BYE": 168 | raise Error("Connection closed by server") 169 | if m.group(1) == "NO": 170 | self.__parse_error(m.group(2)) 171 | raise Response(m.group(1), m.group(2)) 172 | return ret 173 | 174 | def __read_response(self, nblines=-1): 175 | """Read a response from the server. 176 | 177 | In the usual case, we read lines until we find one that looks 178 | like a response (OK|NO|BYE\s*(.+)?). 179 | 180 | If *nblines* > 0, we read excactly nblines before returning. 181 | 182 | :param nblines: number of lines to read (default : -1) 183 | :rtype: tuple 184 | :return: a tuple of the form (code, data, response). If 185 | nblines is provided, code and data can be equal to None. 186 | """ 187 | resp, code, data = ("", None, None) 188 | cpt = 0 189 | while True: 190 | try: 191 | line = self.__read_line() 192 | except Response as inst: 193 | code = inst.code 194 | data = inst.data 195 | break 196 | except Literal as inst: 197 | resp += self.__read_block(inst.value) 198 | continue 199 | if not len(line): 200 | continue 201 | resp += line + CRLF 202 | cpt += 1 203 | if nblines != -1 and cpt == nblines: 204 | break 205 | 206 | return (code, data, resp) 207 | 208 | def __prepare_args(self, args): 209 | """Format command arguments before sending them. 210 | 211 | Command arguments of type string must be quoted, the only 212 | exception concerns size indication (of the form {\d\+?}). 213 | 214 | :param args: list of arguments 215 | :return: a list for transformed arguments 216 | """ 217 | ret = [] 218 | for a in args: 219 | if type(a) in [str, unicode] and self.__size_expr.match(a) is None: 220 | ret += ['"%s"' % a.encode('utf-8')] 221 | continue 222 | ret += ["%s" % str(a)] 223 | return ret 224 | 225 | def __send_command(self, name, args=[], withcontent=False, extralines=[], nblines=-1): 226 | """Send a command to the server. 227 | 228 | If args is not empty, we concatenate the given command with 229 | the content of this list. If extralines is not empty, they are 230 | sent one by one to the server. (CLRF are automatically appended to them) 231 | 232 | We wait for a response just after the command has been sent. 233 | 234 | :param name: the command to sent 235 | :param args: a list of arguments for this command 236 | :param withcontent: tells the function to return the server's response or not 237 | :param extralines: a list of extra lines to sent after the command 238 | :param nblines: the number of response lines to read (all by default) 239 | 240 | :returns: a tuple of the form (code, data[, response]) 241 | """ 242 | tosend = name 243 | if len(args): 244 | tosend += " " + " ".join(self.__prepare_args(args)) 245 | self.__dprint("Command: %s" % tosend) 246 | self.sock.sendall("%s%s" % (tosend, CRLF)) 247 | for l in extralines: 248 | self.sock.sendall("%s%s" % (l, CRLF)) 249 | code, data, content = self.__read_response(nblines) 250 | 251 | if withcontent: 252 | return (code, data, content) 253 | return (code, data) 254 | 255 | def __get_capabilities(self): 256 | code, data, capabilities = self.__read_response() 257 | if code == "NO": 258 | return False 259 | 260 | for l in capabilities.splitlines()[0:-1]: 261 | parts = l.split(None, 1) 262 | cname = parts[0].strip('"') 263 | if not cname in KNOWN_CAPABILITIES: 264 | continue 265 | self.__capabilities[cname] = \ 266 | parts[1].strip('"') if len(parts) > 1 else None 267 | return True 268 | 269 | def __parse_error(self, text): 270 | """Parse an error received from the server. 271 | 272 | if text corresponds to a size indication, we grab the 273 | remaining content from the server. 274 | 275 | Otherwise, we try to match an error of the form \(\w+\)?\s*".+" 276 | 277 | On succes, the two public members errcode and errmsg are 278 | filled with the parsing results. 279 | 280 | :param text: the response to parse 281 | """ 282 | m = self.__size_expr.match(text) 283 | if m is not None: 284 | self.errcode = "" 285 | self.errmsg = self.__read_block(int(m.group(1)) + 2) 286 | return 287 | 288 | m = self.__error_expr.match(text) 289 | if m is None: 290 | raise Error("Bad error message") 291 | if m.group(1) is not None: 292 | self.errcode = m.group(1).strip("()") 293 | else: 294 | self.errcode = "" 295 | self.errmsg = m.group(2).strip('"') 296 | 297 | def _plain_authentication(self, login, password): 298 | """SASL PLAIN authentication 299 | 300 | :param login: username 301 | :param password: clear password 302 | :return: True on success, False otherwise. 303 | """ 304 | if isinstance(login, six.text_type): 305 | login = login.encode("utf-8") 306 | if isinstance(login, six.text_type): 307 | password = password.encode("utf-8") 308 | params = base64.b64encode('\0' + '\0'.join([login, password])) 309 | code, data = self.__send_command("AUTHENTICATE", ["PLAIN", params]) 310 | if code == "OK": 311 | return True 312 | return False 313 | 314 | def _login_authentication(self, login, password): 315 | """SASL LOGIN authentication 316 | 317 | :param login: username 318 | :param password: clear password 319 | :return: True on success, False otherwise. 320 | """ 321 | extralines = ['"%s"' % base64.b64encode(login), 322 | '"%s"' % base64.b64encode(password)] 323 | code, data = self.__send_command("AUTHENTICATE", ["LOGIN"], 324 | extralines=extralines) 325 | if code == "OK": 326 | return True 327 | return False 328 | 329 | def _digest_md5_authentication(self, login, password): 330 | """SASL DIGEST-MD5 authentication 331 | 332 | :param login: username 333 | :param password: clear password 334 | :return: True on success, False otherwise. 335 | """ 336 | code, data, challenge = \ 337 | self.__send_command("AUTHENTICATE", ["DIGEST-MD5"], 338 | withcontent=True, nblines=1) 339 | dmd5 = DigestMD5(challenge, "sieve/%s" % self.srvaddr) 340 | 341 | code, data, challenge = \ 342 | self.__send_command('"%s"' % dmd5.response(login, password), 343 | withcontent=True, nblines=1) 344 | if not len(challenge): 345 | return False 346 | if not dmd5.check_last_challenge(login, password, challenge): 347 | self.errmsg = "Bad challenge received from server" 348 | return False 349 | code, data = self.__send_command('""') 350 | if code == "OK": 351 | return True 352 | return False 353 | 354 | def __authenticate(self, login, password, authmech=None): 355 | """AUTHENTICATE command 356 | 357 | Actually, it is just a wrapper to the real commands (one by 358 | mechanism). We try all supported mechanisms (from the 359 | strongest to the weakest) until we find one supported by the 360 | server. 361 | 362 | Then we try to authenticate (only once). 363 | 364 | :param login: username 365 | :param password: clear password 366 | :param authmech: prefered authentication mechanism 367 | :return: True on success, False otherwise 368 | """ 369 | if not self.__capabilities.has_key("SASL"): 370 | raise Error("SASL not supported by the server") 371 | srv_mechanisms = self.get_sasl_mechanisms() 372 | 373 | if authmech is None or authmech not in SUPPORTED_AUTH_MECHS: 374 | mech_list = SUPPORTED_AUTH_MECHS 375 | else: 376 | mech_list = [authmech] 377 | 378 | for mech in mech_list: 379 | if not mech in srv_mechanisms: 380 | continue 381 | mech = mech.lower() 382 | mech = re.sub("-", "_", mech) 383 | if getattr(self, "_%s_authentication" % mech.lower())(login, password): 384 | self.authenticated = True 385 | return True 386 | return False 387 | 388 | self.errmsg = "No suitable mechanism found" 389 | return False 390 | 391 | def __starttls(self, keyfile=None, certfile=None): 392 | """STARTTLS command 393 | 394 | See MANAGESIEVE specifications, section 2.2. 395 | 396 | :param keyfile: an eventual private key to use 397 | :param certfile: an eventual certificate to use 398 | :rtype: boolean 399 | """ 400 | if not self.has_tls_support(): 401 | raise Error("STARTTLS not supported by the server") 402 | code, data = self.__send_command("STARTTLS") 403 | if code != "OK": 404 | return False 405 | try: 406 | nsock = ssl.wrap_socket(self.sock, keyfile, certfile) 407 | except ssl.SSLError as e: 408 | raise Error("SSL error: %s" % str(e)) 409 | self.sock = nsock 410 | self.__capabilities = {} 411 | self.__get_capabilities() 412 | return True 413 | 414 | def get_implementation(self): 415 | """Returns the IMPLEMENTATION value. 416 | 417 | It is read from server capabilities. (see the CAPABILITY 418 | command) 419 | 420 | :rtype: string 421 | """ 422 | return self.__capabilities["IMPLEMENTATION"] 423 | 424 | def get_sasl_mechanisms(self): 425 | """Returns the supported authentication mechanisms. 426 | 427 | They're read from server capabilities. (see the CAPABILITY 428 | command) 429 | 430 | :rtype: list of string 431 | """ 432 | return self.__capabilities["SASL"].split() 433 | 434 | def has_tls_support(self): 435 | """Tells if the server has STARTTLS support or not. 436 | 437 | It is read from server capabilities. (see the CAPABILITY 438 | command) 439 | 440 | :rtype: boolean 441 | """ 442 | return self.__capabilities.has_key("STARTTLS") 443 | 444 | def get_sieve_capabilities(self): 445 | """Returns the SIEVE extensions supported by the server. 446 | 447 | They're read from server capabilities. (see the CAPABILITY 448 | command) 449 | 450 | :rtype: string 451 | """ 452 | if type(self.__capabilities["SIEVE"]) == str: 453 | self.__capabilities["SIEVE"] = self.__capabilities["SIEVE"].split() 454 | return self.__capabilities["SIEVE"] 455 | 456 | def connect(self, login, password, starttls=False, authmech=None): 457 | """Establish a connection with the server. 458 | 459 | This function must be used. It read the server capabilities 460 | and wraps calls to STARTTLS and AUTHENTICATE commands. 461 | 462 | :param login: username 463 | :param password: clear password 464 | :param starttls: use a TLS connection or not 465 | :param authmech: prefered authenticate mechanism 466 | :rtype: boolean 467 | """ 468 | try: 469 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 470 | self.sock.connect((self.srvaddr, self.srvport)) 471 | self.sock.settimeout(Client.read_timeout) 472 | except socket.error as msg: 473 | raise Error("Connection to server failed: %s" % str(msg)) 474 | 475 | if not self.__get_capabilities(): 476 | raise Error("Failed to read capabilities from server") 477 | if starttls and not self.__starttls(): 478 | return False 479 | if self.__authenticate(login, password, authmech): 480 | return True 481 | return False 482 | 483 | def logout(self): 484 | """Disconnect from the server 485 | 486 | See MANAGESIEVE specifications, section 2.3 487 | """ 488 | self.__send_command("LOGOUT") 489 | 490 | def capability(self): 491 | """Ask server capabilities. 492 | 493 | See MANAGESIEVE specifications, section 2.4 This command does 494 | not affect capabilities recorded by this client. 495 | 496 | :rtype: string 497 | """ 498 | code, data, capabilities = \ 499 | self.__send_command("CAPABILITY", withcontent=True) 500 | if code == "OK": 501 | return capabilities 502 | return None 503 | 504 | @authentication_required 505 | def havespace(self, scriptname, scriptsize): 506 | """Ask for available space. 507 | 508 | See MANAGESIEVE specifications, section 2.5 509 | 510 | :param scriptname: script's name 511 | :param scriptsize: script's size 512 | :rtype: boolean 513 | """ 514 | code, data = self.__send_command("HAVESPACE", [scriptname, scriptsize]) 515 | if code == "OK": 516 | return True 517 | return False 518 | 519 | @authentication_required 520 | def listscripts(self): 521 | """List available scripts. 522 | 523 | See MANAGESIEVE specifications, section 2.7 524 | 525 | :returns: a 2-uple (active script, [script1, ...]) 526 | """ 527 | code, data, listing = self.__send_command("LISTSCRIPTS", withcontent=True) 528 | if code == "NO": 529 | return None 530 | ret = [] 531 | active_script = None 532 | for l in listing.splitlines(): 533 | if self.__size_expr.match(l): 534 | continue 535 | m = re.match(r'"([^"]+)"\s*(.+)', l) 536 | if m is None: 537 | ret += [l.strip('"')] 538 | else: 539 | if self.__active_expr.match(m.group(2)): 540 | active_script = m.group(1) 541 | else: 542 | ret += [m.group(1)] 543 | self.__dprint(ret) 544 | return (active_script, ret) 545 | 546 | @authentication_required 547 | def getscript(self, name): 548 | """Download a script from the server 549 | 550 | See MANAGESIEVE specifications, section 2.9 551 | 552 | :param name: script's name 553 | :rtype: string 554 | :returns: the script's content on succes, None otherwise 555 | """ 556 | code, data, content = self.__send_command("GETSCRIPT", [name], withcontent=True) 557 | if code == "OK": 558 | lines = content.splitlines() 559 | if self.__size_expr.match(lines[0]) is not None: 560 | lines = lines[1:] 561 | return "\n".join(lines) 562 | return None 563 | 564 | @authentication_required 565 | def putscript(self, name, content): 566 | """Upload a script to the server 567 | 568 | See MANAGESIEVE specifications, section 2.6 569 | 570 | :param name: script's name 571 | :param content: script's content 572 | :rtype: boolean 573 | """ 574 | if type(content) is unicode: 575 | content = content.encode("utf-8") 576 | 577 | content = "{%d+}%s%s" % (len(content), CRLF, content) 578 | code, data = \ 579 | self.__send_command("PUTSCRIPT", [name, content]) 580 | if code == "OK": 581 | return True 582 | return False 583 | 584 | @authentication_required 585 | def deletescript(self, name): 586 | """Delete a script from the server 587 | 588 | See MANAGESIEVE specifications, section 2.10 589 | 590 | :param name: script's name 591 | :rtype: boolean 592 | """ 593 | code, data = self.__send_command("DELETESCRIPT", [name]) 594 | if code == "OK": 595 | return True 596 | return False 597 | 598 | @authentication_required 599 | def renamescript(self, oldname, newname): 600 | """Rename a script on the server 601 | 602 | See MANAGESIEVE specifications, section 2.11.1 603 | 604 | As this command is optional, we emulate it if the server does 605 | not support it. 606 | 607 | :param oldname: current script's name 608 | :param newname: new script's name 609 | :rtype: boolean 610 | """ 611 | if self.__capabilities.has_key("RENAMESCRIPT"): 612 | code, data = self.__send_command("RENAMESCRIPT", [oldname, newname]) 613 | if code == "OK": 614 | return True 615 | return False 616 | 617 | (active_script, scripts) = self.listscripts() 618 | if scripts is None or not oldname in scripts: 619 | self.errmsg = "Old script does not exist" 620 | return False 621 | if newname in scripts: 622 | self.errmsg = "New script already exists" 623 | return False 624 | oldscript = self.getscript(oldname) 625 | if oldscript is None: 626 | return False 627 | if not self.putscript(newname, oldscript): 628 | return False 629 | if active_script == oldname: 630 | if not self.setactive(newname): 631 | return False 632 | if not self.deletescript(oldname): 633 | return False 634 | return True 635 | 636 | @authentication_required 637 | def setactive(self, scriptname): 638 | """Define the active script 639 | 640 | See MANAGESIEVE specifications, section 2.8 641 | 642 | If scriptname is empty, the current active script is disabled, 643 | ie. there will be no active script anymore. 644 | 645 | :param scriptname: script's name 646 | :rtype: boolean 647 | """ 648 | code, data = self.__send_command("SETACTIVE", [scriptname]) 649 | if code == "OK": 650 | return True 651 | return False 652 | 653 | @authentication_required 654 | def checkscript(self, content): 655 | """Check whether a script is valid 656 | 657 | See MANAGESIEVE specifications, section 2.12 658 | 659 | :param name: script's content 660 | :rtype: boolean 661 | """ 662 | if type(content) is unicode: 663 | content = content.encode("utf-8") 664 | 665 | content = "{%d+}%s%s" % (len(content), CRLF, content) 666 | code, data = \ 667 | self.__send_command("CHECKSCRIPT", [content]) 668 | if code == "OK": 669 | return True 670 | return False 671 | -------------------------------------------------------------------------------- /sievelib/commands.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | SIEVE commands representation 5 | 6 | This module contains classes that represent known commands. They all 7 | inherit from the Command class which provides generic method for 8 | command manipulation or parsing. 9 | 10 | There are three command types (each one represented by a class): 11 | * control (ControlCommand) : Control structures are needed to allow 12 | for multiple and conditional actions 13 | * action (ActionCommand) : Actions that can be applied on emails 14 | * test (TestCommand) : Tests are used in conditionals to decide which 15 | part(s) of the conditional to execute 16 | 17 | Finally, each known command is represented by its own class which 18 | provides extra information such as: 19 | * expected arguments, 20 | * completion callback, 21 | * etc. 22 | 23 | """ 24 | from __future__ import unicode_literals 25 | 26 | import sys 27 | from collections import Iterable 28 | 29 | 30 | class UnknownCommand(Exception): 31 | """Specific exception raised when an unknown command is encountered""" 32 | 33 | def __init__(self, name): 34 | self.name = name 35 | 36 | def __str__(self): 37 | return "unknown command %s" % self.name 38 | 39 | 40 | class BadArgument(Exception): 41 | """Specific exception raised when a bad argument is encountered""" 42 | 43 | def __init__(self, command, seen, expected): 44 | self.command = command 45 | self.seen = seen 46 | self.expected = expected 47 | 48 | def __str__(self): 49 | return "bad argument %s for command %s (%s expected)" \ 50 | % (self.seen, self.command, self.expected) 51 | 52 | 53 | class BadValue(Exception): 54 | """Specific exception raised when a bad argument value is encountered""" 55 | 56 | def __init__(self, argument, value): 57 | self.argument = argument 58 | self.value = value 59 | 60 | def __str__(self): 61 | return "bad value %s for argument %s" \ 62 | % (self.value, self.argument) 63 | 64 | 65 | # Statement elements (see RFC, section 8.3) 66 | # They are used in different commands. 67 | comparator = {"name": "comparator", 68 | "type": ["tag"], 69 | "values": [":comparator"], 70 | "extra_arg": {"type": "string", 71 | "values": ['"i;octet"', '"i;ascii-casemap"']}, 72 | "required": False} 73 | address_part = {"name": "address-part", 74 | "values": [":localpart", ":domain", ":all"], 75 | "type": ["tag"], 76 | "required": False} 77 | match_type = {"name": "match-type", 78 | "values": [":is", ":contains", ":matches"], 79 | "type": ["tag"], 80 | "required": False} 81 | 82 | 83 | class Command(object): 84 | """Generic command representation. 85 | 86 | A command is described as follow: 87 | * A name 88 | * A type 89 | * A description of supported arguments 90 | * Does it accept an unknown quantity of arguments? (ex: anyof, allof) 91 | * Does it accept children? (ie. subcommands) 92 | * Is it an extension? 93 | * Must follow only certain commands 94 | 95 | """ 96 | _type = None 97 | variable_args_nb = False 98 | accept_children = False 99 | must_follow = None 100 | is_extension = False 101 | 102 | def __init__(self, parent=None): 103 | self.parent = parent 104 | self.arguments = {} 105 | self.children = [] 106 | 107 | self.nextargpos = 0 108 | self.required_args = -1 109 | self.rargs_cnt = 0 110 | self.curarg = None # for arguments that expect an argument :p (ex: :comparator) 111 | 112 | self.name = self.__class__.__name__.replace("Command", "") 113 | self.name = self.name.lower() 114 | 115 | self.hash_comments = [] 116 | 117 | def __repr__(self): 118 | return "%s (type: %s)" % (self.name, self._type) 119 | 120 | def tosieve(self, indentlevel=0, target=sys.stdout): 121 | """Generate the sieve syntax corresponding to this command 122 | 123 | Recursive method. 124 | 125 | :param indentlevel: current indentation level 126 | :param target: opened file pointer where the content will be printed 127 | """ 128 | self.__print(self.name, indentlevel, nocr=True, target=target) 129 | if self.has_arguments(): 130 | for arg in self.args_definition: 131 | if not arg["name"] in self.arguments: 132 | continue 133 | target.write(" ") 134 | value = self.arguments[arg["name"]] 135 | 136 | if "tag" in arg["type"] and arg.get("write_tag", False): 137 | target.write("%s " % arg["values"][0]) 138 | 139 | if type(value) == list: 140 | if self.__get_arg_type(arg["name"]) == ["testlist"]: 141 | target.write("(") 142 | for t in value: 143 | t.tosieve(target=target) 144 | if value.index(t) != len(value) - 1: 145 | target.write(", ") 146 | target.write(")") 147 | else: 148 | target.write("[" + ((", ".join(['"%s"' % v.strip('"') for v in value]))) + "]") 149 | continue 150 | if isinstance(value, Command): 151 | value.tosieve(indentlevel, target=target) 152 | continue 153 | 154 | if "string" in arg["type"]: 155 | target.write(value) 156 | if not value.startswith('"'): 157 | target.write("\n") 158 | else: 159 | target.write(value) 160 | 161 | if not self.accept_children: 162 | if self.get_type() != "test": 163 | target.write(";\n") 164 | return 165 | if self.get_type() != "control": 166 | return 167 | target.write(" {\n") 168 | for ch in self.children: 169 | ch.tosieve(indentlevel + 4, target=target) 170 | self.__print("}", indentlevel, target=target) 171 | 172 | def __print(self, data, indentlevel, nocr=False, target=sys.stdout): 173 | text = "%s%s" % (" " * indentlevel, data) 174 | if nocr: 175 | target.write(text) 176 | else: 177 | target.write(text + "\n") 178 | 179 | def __get_arg_type(self, arg): 180 | """Return the type corresponding to the given name. 181 | 182 | :param arg: a defined argument name 183 | """ 184 | for a in self.args_definition: 185 | if a["name"] == arg: 186 | return a["type"] 187 | return None 188 | 189 | def complete_cb(self): 190 | """Completion callback 191 | 192 | Called when a command is considered as complete by the parser. 193 | """ 194 | pass 195 | 196 | def get_expected_first(self): 197 | """Return the first expected token for this command""" 198 | return None 199 | 200 | def has_arguments(self): 201 | return len(self.args_definition) != 0 202 | 203 | def dump(self, indentlevel=0, target=sys.stdout): 204 | """Display the command 205 | 206 | Pretty printing of this command and its eventual arguments and 207 | children. (recursively) 208 | 209 | :param indentlevel: integer that indicates indentation level to apply 210 | """ 211 | self.__print(self, indentlevel, target=target) 212 | indentlevel += 4 213 | if self.has_arguments(): 214 | for arg in self.args_definition: 215 | if not arg["name"] in self.arguments: 216 | continue 217 | value = self.arguments[arg["name"]] 218 | if type(value) == list: 219 | if self.__get_arg_type(arg["name"]) == ["testlist"]: 220 | for t in value: 221 | t.dump(indentlevel, target) 222 | else: 223 | self.__print("[" + (",".join(value)) + "]", indentlevel, target=target) 224 | continue 225 | if isinstance(value, Command): 226 | value.dump(indentlevel, target) 227 | continue 228 | self.__print(str(value), indentlevel, target=target) 229 | for ch in self.children: 230 | ch.dump(indentlevel, target) 231 | 232 | def addchild(self, child): 233 | """Add a new child to the command 234 | 235 | A child corresponds to a command located into a block (this 236 | command's block). It can be either an action or a control. 237 | 238 | :param child: the new child 239 | :return: True on succes, False otherwise 240 | """ 241 | if not self.accept_children: 242 | return False 243 | self.children += [child] 244 | return True 245 | 246 | def iscomplete(self): 247 | """Check if the command is complete 248 | 249 | Check if all required arguments have been encountered. For 250 | commands that allow an undefined number of arguments, this 251 | method always returns False. 252 | 253 | :return: True if command is complete, False otherwise 254 | """ 255 | if self.variable_args_nb: 256 | return False 257 | if self.required_args == -1: 258 | self.required_args = 0 259 | for arg in self.args_definition: 260 | if arg["required"]: 261 | self.required_args += 1 262 | return (not self.curarg or not "extra_arg" in self.curarg) \ 263 | and (self.rargs_cnt == self.required_args) 264 | 265 | def get_type(self): 266 | """Return the command's type""" 267 | if self._type is None: 268 | raise NotImplementedError 269 | return self._type 270 | 271 | def __is_valid_value_for_arg(self, arg, value): 272 | """Check if value is allowed for arg 273 | 274 | Some commands only allow a limited set of values. The method 275 | always returns True for methods that do not provide such a 276 | set. 277 | 278 | :param arg: the argument's name 279 | :param value: the value to check 280 | :return: True on succes, False otherwise 281 | """ 282 | if not "values" in arg: 283 | return True 284 | return value.lower() in arg["values"] 285 | 286 | def check_next_arg(self, atype, avalue, add=True): 287 | """Argument validity checking 288 | 289 | This method is usually used by the parser to check if detected 290 | argument is allowed for this command. 291 | 292 | We make a distinction between required and optional 293 | arguments. Optional (or tagged) arguments can be provided 294 | unordered but not the required ones. 295 | 296 | A special handling is also done for arguments that require an 297 | argument (example: the :comparator argument expects a string 298 | argument). 299 | 300 | The "testlist" type is checked separately as we can't know in 301 | advance how many arguments will be provided. 302 | 303 | If the argument is incorrect, the method raises the 304 | appropriate exception, or return False to let the parser 305 | handle the exception. 306 | 307 | :param atype: the argument's type 308 | :param avalue: the argument's value 309 | :param add: indicates if this argument should be recorded on success 310 | :return: True on success, False otherwise 311 | """ 312 | if not self.has_arguments(): 313 | return False 314 | if self.iscomplete(): 315 | return False 316 | 317 | if self.curarg is not None and "extra_arg" in self.curarg: 318 | if atype == self.curarg["extra_arg"]["type"]: 319 | if not "values" in self.curarg["extra_arg"] \ 320 | or avalue in self.curarg["extra_arg"]["values"]: 321 | if add: 322 | self.arguments[self.curarg["name"]] = avalue 323 | self.curarg = None 324 | return True 325 | raise BadValue(self.curarg["name"], avalue) 326 | 327 | failed = False 328 | pos = self.nextargpos 329 | while pos < len(self.args_definition): 330 | curarg = self.args_definition[pos] 331 | if curarg["required"]: 332 | if curarg["type"] == ["testlist"]: 333 | if atype != "test": 334 | failed = True 335 | elif add: 336 | if not curarg["name"] in self.arguments: 337 | self.arguments[curarg["name"]] = [] 338 | self.arguments[curarg["name"]] += [avalue] 339 | elif atype not in curarg["type"] or \ 340 | not self.__is_valid_value_for_arg(curarg, avalue): 341 | failed = True 342 | else: 343 | self.curarg = curarg 344 | self.rargs_cnt += 1 345 | self.nextargpos = pos + 1 346 | if add: 347 | self.arguments[curarg["name"]] = avalue 348 | break 349 | 350 | if atype in curarg["type"]: 351 | if self.__is_valid_value_for_arg(curarg, avalue): 352 | if "extra_arg" in curarg: 353 | self.curarg = curarg 354 | break 355 | if add: 356 | self.arguments[curarg["name"]] = avalue 357 | break 358 | 359 | pos += 1 360 | 361 | if failed: 362 | raise BadArgument(self.name, avalue, 363 | self.args_definition[pos]["type"]) 364 | return True 365 | 366 | def __getitem__(self, name): 367 | """Shorcut to access a command argument 368 | 369 | :param name: the argument's name 370 | """ 371 | found = False 372 | for ad in self.args_definition: 373 | if ad["name"] == name: 374 | found = True 375 | break 376 | if not found: 377 | raise KeyError(name) 378 | if not name in self.arguments: 379 | raise KeyError(name) 380 | return self.arguments[name] 381 | 382 | 383 | class ControlCommand(Command): 384 | """Indermediate class to represent "control" commands""" 385 | _type = "control" 386 | 387 | 388 | class RequireCommand(ControlCommand): 389 | """The 'require' command 390 | 391 | This class has one big difference with others as it is used to 392 | store loaded extension names. (The result is we can check for 393 | unloaded extensions during the parsing) 394 | """ 395 | args_definition = [ 396 | {"name": "capabilities", 397 | "type": ["string", "stringlist"], 398 | "required": True} 399 | ] 400 | 401 | loaded_extensions = [] 402 | 403 | def complete_cb(self): 404 | if type(self.arguments["capabilities"]) == str: 405 | exts = [self.arguments["capabilities"]] 406 | else: 407 | exts = self.arguments["capabilities"] 408 | for ext in exts: 409 | ext = ext.strip('"') 410 | if not ext in RequireCommand.loaded_extensions: 411 | RequireCommand.loaded_extensions += [ext] 412 | 413 | 414 | class StopCommand(ControlCommand): 415 | args_definition = [] 416 | 417 | 418 | class IfCommand(ControlCommand): 419 | accept_children = True 420 | 421 | args_definition = [ 422 | {"name": "test", 423 | "type": ["test"], 424 | "required": True} 425 | ] 426 | 427 | def get_expected_first(self): 428 | return ["identifier"] 429 | 430 | 431 | class ElsifCommand(ControlCommand): 432 | accept_children = True 433 | must_follow = ["if", "elsif"] 434 | args_definition = [ 435 | {"name": "test", 436 | "type": ["test"], 437 | "required": True} 438 | ] 439 | 440 | def get_expected_first(self): 441 | return ["identifier"] 442 | 443 | 444 | class ElseCommand(ControlCommand): 445 | accept_children = True 446 | must_follow = ["if", "elsif"] 447 | args_definition = [] 448 | 449 | 450 | class ActionCommand(Command): 451 | """Indermediate class to represent "action" commands""" 452 | _type = "action" 453 | 454 | 455 | class FileintoCommand(ActionCommand): 456 | is_extension = True 457 | args_definition = [ 458 | {"name": "mailbox", 459 | "type": ["string"], 460 | "required": True} 461 | ] 462 | 463 | 464 | class RedirectCommand(ActionCommand): 465 | args_definition = [ 466 | {"name": "address", 467 | "type": ["string"], 468 | "required": True} 469 | ] 470 | 471 | 472 | class RejectCommand(ActionCommand): 473 | is_extension = True 474 | args_definition = [ 475 | {"name": "text", 476 | "type": ["string"], 477 | "required": True} 478 | ] 479 | 480 | 481 | class KeepCommand(ActionCommand): 482 | args_definition = [] 483 | 484 | 485 | class DiscardCommand(ActionCommand): 486 | args_definition = [] 487 | 488 | 489 | class TestCommand(Command): 490 | """Indermediate class to represent "test" commands""" 491 | _type = "test" 492 | 493 | 494 | class AddressCommand(TestCommand): 495 | args_definition = [ 496 | comparator, 497 | address_part, 498 | match_type, 499 | {"name": "header-list", 500 | "type": ["string", "stringlist"], 501 | "required": True}, 502 | {"name": "key-list", 503 | "type": ["string", "stringlist"], 504 | "required": True} 505 | ] 506 | 507 | 508 | class AllofCommand(TestCommand): 509 | accept_children = True 510 | variable_args_nb = True 511 | 512 | args_definition = [ 513 | {"name": "tests", 514 | "type": ["testlist"], 515 | "required": True} 516 | ] 517 | 518 | def get_expected_first(self): 519 | return ["left_parenthesis"] 520 | 521 | 522 | class AnyofCommand(TestCommand): 523 | accept_children = True 524 | variable_args_nb = True 525 | 526 | args_definition = [ 527 | {"name": "tests", 528 | "type": ["testlist"], 529 | "required": True} 530 | ] 531 | 532 | def get_expected_first(self): 533 | return ["left_parenthesis"] 534 | 535 | 536 | class EnvelopeCommand(TestCommand): 537 | args_definition = [ 538 | comparator, 539 | address_part, 540 | match_type, 541 | {"name": "header-list", 542 | "type": ["string", "stringlist"], 543 | "required": True}, 544 | {"name": "key-list", 545 | "type": ["string", "stringlist"], 546 | "required": True} 547 | ] 548 | 549 | 550 | class ExistsCommand(TestCommand): 551 | args_definition = [ 552 | {"name": "header-names", 553 | "type": ["stringlist"], 554 | "required": True} 555 | ] 556 | 557 | 558 | class TrueCommand(TestCommand): 559 | args_definition = [] 560 | 561 | 562 | class FalseCommand(TestCommand): 563 | args_definition = [] 564 | 565 | 566 | class HeaderCommand(TestCommand): 567 | args_definition = [ 568 | comparator, 569 | match_type, 570 | {"name": "header-names", 571 | "type": ["string", "stringlist"], 572 | "required": True}, 573 | {"name": "key-list", 574 | "type": ["string", "stringlist"], 575 | "required": True} 576 | ] 577 | 578 | 579 | class NotCommand(TestCommand): 580 | accept_children = True 581 | 582 | args_definition = [ 583 | {"name": "test", 584 | "type": ["test"], 585 | "required": True} 586 | ] 587 | 588 | def get_expected_first(self): 589 | return ["identifier"] 590 | 591 | 592 | class SizeCommand(TestCommand): 593 | args_definition = [ 594 | {"name": "comparator", 595 | "type": ["tag"], 596 | "values": [":over", ":under"], 597 | "required": True}, 598 | {"name": "limit", 599 | "type": ["number"], 600 | "required": True}, 601 | ] 602 | 603 | 604 | class VacationCommand(ActionCommand): 605 | args_definition = [ 606 | {"name": "subject", 607 | "type": ["tag"], 608 | "write_tag": True, 609 | "values": [":subject"], 610 | "extra_arg": {"type": "string"}, 611 | "required": False}, 612 | {"name": "days", 613 | "type": ["tag"], 614 | "write_tag": True, 615 | "values": [":days"], 616 | "extra_arg": {"type": "number"}, 617 | "required": False}, 618 | {"name": "from", 619 | "type": ["tag"], 620 | "write_tag": True, 621 | "values": [":from"], 622 | "extra_arg": {"type": "string"}, 623 | "required": False}, 624 | {"name": "addresses", 625 | "type": ["tag"], 626 | "write_tag": True, 627 | "values": [":addresses"], 628 | "extra_arg": {"type": "stringlist"}, 629 | "required": False}, 630 | {"name": "handle", 631 | "type": ["tag"], 632 | "write_tag": True, 633 | "values": [":handle"], 634 | "extra_arg": {"type": "string"}, 635 | "required": False}, 636 | {"name": "mime", 637 | "type": ["tag"], 638 | "write_tag": True, 639 | "values": [":mime"], 640 | "required": False}, 641 | {"name": "reason", 642 | "type": ["string"], 643 | "required": True}, 644 | ] 645 | 646 | class SetCommand(ControlCommand): 647 | """currentdate command, part of the variables extension 648 | 649 | http://tools.ietf.org/html/rfc5229 650 | """ 651 | is_extension = True 652 | args_definition = [ 653 | {"name": "startend", 654 | "type": ["string"], 655 | "required": True}, 656 | {"name": "date", 657 | "type": ["string"], 658 | "required": True} 659 | ] 660 | 661 | class CurrentdateCommand(ControlCommand): 662 | """currentdate command, part of the date extension 663 | 664 | http://tools.ietf.org/html/rfc5260#section-5 665 | """ 666 | is_extension = True 667 | accept_children = True 668 | args_definition = [ 669 | {"name": "zone", 670 | "type": ["tag"], 671 | "write_tag": True, 672 | "values": [":zone"], 673 | "extra_arg": {"type": "string"}, 674 | "required": False}, 675 | {"name": "match-value", 676 | "type": ["tag"], 677 | "required": True}, 678 | {"name": "comparison", 679 | "type": ["string"], 680 | "required": True}, 681 | {"name": "match-against", 682 | "type": ["string"], 683 | "required": True}, 684 | {"name": "match-against-field", 685 | "type": ["string"], 686 | "required": True} 687 | ] 688 | 689 | def add_commands(cmds): 690 | """ 691 | Adds one or more commands to the module namespace. 692 | Commands must end in "Command" to be added. 693 | Example (see tests/parser.py): 694 | sievelib.commands.add_commands(MytestCommand) 695 | 696 | :param cmds: a single Command Object or list of Command Objects 697 | """ 698 | if not isinstance(cmds, Iterable): 699 | cmds = [cmds] 700 | 701 | for command in cmds: 702 | if command.__name__.endswith("Command"): 703 | globals()[command.__name__] = command 704 | 705 | 706 | def get_command_instance(name, parent=None, checkexists=True): 707 | """Try to guess and create the appropriate command instance 708 | 709 | Given a command name (encountered by the parser), construct the 710 | associated class name and, if known, return a new instance. 711 | 712 | If the command is not known or has not been loaded using require, 713 | an UnknownCommand exception is raised. 714 | 715 | :param name: the command's name 716 | :param parent: the eventual parent command 717 | :return: a new class instance 718 | """ 719 | 720 | # Mapping between extension names and command names 721 | extension_map = {'date': set([ 722 | 'currentdate']), 723 | 'variables': set([ 724 | 'set'])} 725 | extname = name 726 | for extension in extension_map: 727 | if name in extension_map[extension]: 728 | extname = extension 729 | break 730 | 731 | cname = "%sCommand" % name.lower().capitalize() 732 | if not cname in globals() or \ 733 | (checkexists and globals()[cname].is_extension and 734 | not extname in RequireCommand.loaded_extensions): 735 | raise UnknownCommand(name) 736 | return globals()[cname](parent) 737 | --------------------------------------------------------------------------------