├── imapautofiler ├── __init__.py ├── tests │ ├── __init__.py │ ├── test_lookup.py │ ├── test_secrets.py │ ├── base.py │ ├── test_maildir.py │ ├── test_actions.py │ └── test_rules.py ├── i18n.py ├── lookup.py ├── config.py ├── secrets.py ├── app.py ├── client.py ├── rules.py └── actions.py ├── doc └── source │ ├── _static │ └── .placeholder │ ├── _templates │ └── .placeholder │ ├── api │ ├── imapautofiler.app.rst │ ├── imapautofiler.i18n.rst │ ├── imapautofiler.rules.rst │ ├── imapautofiler.actions.rst │ ├── imapautofiler.client.rst │ ├── imapautofiler.config.rst │ ├── imapautofiler.lookup.rst │ └── autoindex.rst │ ├── installing.rst │ ├── index.rst │ ├── running.rst │ ├── contributing.rst │ ├── conf.py │ └── configuring.rst ├── .coveragerc ├── requirements.txt ├── .testr.conf ├── .gitignore ├── CONTRIBUTING.rst ├── README.rst ├── tox.ini ├── tools ├── travis.sh └── maildir_test_data.py ├── .travis.yml ├── setup.py ├── setup.cfg └── LICENSE /imapautofiler/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/source/_static/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imapautofiler/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/source/_templates/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = imapautofiler/tests/* -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML>=3.11 2 | imapclient>=1.0.1,<2.0.0 3 | keyring>=10.0.0 4 | -------------------------------------------------------------------------------- /.testr.conf: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | test_command=${PYTHON:-python} -m subunit.run discover -t ./ . $LISTOPT $IDOPTION 3 | test_id_option=--load-list $IDFILE 4 | test_list_option=--list 5 | -------------------------------------------------------------------------------- /doc/source/api/imapautofiler.app.rst: -------------------------------------------------------------------------------- 1 | The :mod:`imapautofiler.app` Module 2 | =================================== 3 | 4 | .. automodule:: imapautofiler.app 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/source/api/imapautofiler.i18n.rst: -------------------------------------------------------------------------------- 1 | The :mod:`imapautofiler.i18n` Module 2 | ==================================== 3 | 4 | .. automodule:: imapautofiler.i18n 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/source/api/imapautofiler.rules.rst: -------------------------------------------------------------------------------- 1 | The :mod:`imapautofiler.rules` Module 2 | ===================================== 3 | 4 | .. automodule:: imapautofiler.rules 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/source/api/imapautofiler.actions.rst: -------------------------------------------------------------------------------- 1 | The :mod:`imapautofiler.actions` Module 2 | ======================================= 3 | 4 | .. automodule:: imapautofiler.actions 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/source/api/imapautofiler.client.rst: -------------------------------------------------------------------------------- 1 | The :mod:`imapautofiler.client` Module 2 | ====================================== 3 | 4 | .. automodule:: imapautofiler.client 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/source/api/imapautofiler.config.rst: -------------------------------------------------------------------------------- 1 | The :mod:`imapautofiler.config` Module 2 | ====================================== 3 | 4 | .. automodule:: imapautofiler.config 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/source/api/imapautofiler.lookup.rst: -------------------------------------------------------------------------------- 1 | The :mod:`imapautofiler.lookup` Module 2 | ====================================== 3 | 4 | .. automodule:: imapautofiler.lookup 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | AUTHORS 2 | ChangeLog 3 | *~ 4 | *.swp 5 | *.pyc 6 | *.log 7 | .tox 8 | .coverage 9 | imapautofiler.egg-info/ 10 | build/ 11 | doc/build/ 12 | dist/ 13 | .testrepository/ 14 | .project 15 | pbr-*.egg/ 16 | cover/ 17 | .coverage.* 18 | .cache 19 | .eggs/ 20 | -------------------------------------------------------------------------------- /doc/source/api/autoindex.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 1 3 | 4 | imapautofiler.actions.rst 5 | imapautofiler.app.rst 6 | imapautofiler.client.rst 7 | imapautofiler.config.rst 8 | imapautofiler.i18n.rst 9 | imapautofiler.lookup.rst 10 | imapautofiler.rules.rst 11 | -------------------------------------------------------------------------------- /doc/source/installing.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installing 3 | ============ 4 | 5 | Install ``imapautofiler`` with pip_ under Python 3.5 or greater. 6 | 7 | .. code-block:: text 8 | 9 | $ pip install imapautofiler 10 | 11 | .. note:: 12 | 13 | Using a virtualenv is a good practice. 14 | 15 | .. _pip: https://pypi.python.org/pypi/pip 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | The source code and bug tracker for imapautofiler are hosted on github. 2 | 3 | https://github.com/dhellmann/imapautofiler 4 | 5 | The source code is released under the Apache 2.0 license. All patches 6 | should use the same license. 7 | 8 | When reporting a bug, please specify the version of imapautofiler you 9 | are using. 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | imapautofiler 3 | =============== 4 | 5 | imapautofiler is a tool for managing messages on an IMAP server based 6 | on rules for matching properties such as the recipient or header 7 | content. 8 | 9 | * Github: https://github.com/dhellmann/imapautofiler 10 | * Documentation: http://imapautofiler.readthedocs.io/ 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | distribute = False 3 | envlist = pep8,py35,py36 4 | 5 | [testenv] 6 | deps = .[test] 7 | setenv = VIRTUAL_ENV={envdir} 8 | commands = 9 | {toxinidir}/tools/travis.sh '{posargs}' 10 | 11 | [testenv:pep8] 12 | basepython = python3.5 13 | commands = flake8 14 | 15 | [flake8] 16 | show-source = True 17 | exclude = .tox,dist,doc,*.egg,build 18 | 19 | [testenv:docs] 20 | deps = 21 | .[docs] 22 | commands = 23 | python setup.py build_sphinx 24 | 25 | [testenv:testdata] 26 | deps = 27 | .[test] 28 | commands = 29 | {toxinidir}/tools/maildir_test_data.py {posargs} 30 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | imapautofiler 3 | =============== 4 | 5 | imapautofiler is a tool for managing messages on an IMAP server based 6 | on rules for matching properties such as the recipient or header 7 | content. The author uses it to move messages from his "Sent" folder to 8 | the appropriate archive folders. 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | :caption: Contents: 13 | 14 | installing 15 | configuring 16 | running 17 | contributing 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /tools/travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Run the build mode specified by the BUILD variable, defined in 4 | # .travis.yml. When the variable is unset, assume we should run the 5 | # standard test suite. 6 | 7 | rootdir=$(dirname $(dirname $0)) 8 | 9 | # Show the commands being run. 10 | set -x 11 | 12 | # Exit on any error. 13 | set -e 14 | 15 | case "$BUILD" in 16 | docs) 17 | python setup.py build_sphinx;; 18 | linter) 19 | flake8;; 20 | *) 21 | pytest -v \ 22 | --cov=imapautofiler \ 23 | --cov-report term-missing \ 24 | --cov-config $rootdir/.coveragerc \ 25 | $@;; 26 | esac 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.5" 5 | - "3.6" 6 | - "nightly" 7 | 8 | # Test the nightly build of cpython, but ignore any failures. 9 | # Add separate test environments for the docs and flake8 linter. 10 | matrix: 11 | allow_failures: 12 | - python: "nightly" 13 | include: 14 | - env: BUILD=docs 15 | - env: BUILD=linter 16 | 17 | # travis pre-installs some packages that may conflict with our test 18 | # dependencies, so remove them before we set ourselves up. Also 19 | # install pbr to avoid any setup_requires funkiness with 20 | # pip/setuptools. 21 | before_install: 22 | - pip uninstall -y nose mock 23 | - pip install pbr 24 | 25 | install: pip install .[test] .[docs] 26 | 27 | script: 28 | - ./tools/travis.sh -------------------------------------------------------------------------------- /imapautofiler/i18n.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | from email.header import decode_header, make_header 14 | 15 | 16 | def get_header_value(msg, name): 17 | "Handle header decoding and return a string we examine." 18 | return str(make_header(decode_header(msg.get(name, '')))) 19 | -------------------------------------------------------------------------------- /imapautofiler/lookup.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | 14 | def _all_subclasses(cls): 15 | direct = cls.__subclasses__() 16 | yield from direct 17 | for d in direct: 18 | yield from _all_subclasses(d) 19 | 20 | 21 | def make_lookup_table(cls, attr_name): 22 | table = { 23 | getattr(subcls, attr_name, None): subcls 24 | for subcls in _all_subclasses(cls) 25 | if getattr(subcls, attr_name, None) 26 | } 27 | if getattr(cls, attr_name, None): 28 | table[getattr(cls, attr_name)] = cls 29 | return table 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) 2013 Hewlett-Packard Development Company, L.P. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 13 | # implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT 18 | import setuptools 19 | 20 | # In python < 2.7.4, a lazy loading of package `pbr` will break 21 | # setuptools if some other modules registered functions in `atexit`. 22 | # solution from: http://bugs.python.org/issue15881#msg170215 23 | try: 24 | import multiprocessing # noqa 25 | except ImportError: 26 | pass 27 | 28 | setuptools.setup( 29 | setup_requires=['pbr'], 30 | pbr=True) 31 | -------------------------------------------------------------------------------- /imapautofiler/tests/test_lookup.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import unittest 14 | 15 | from imapautofiler import lookup 16 | 17 | 18 | class TestLookupTable(unittest.TestCase): 19 | 20 | def test_create(self): 21 | class A: 22 | NAME = 'a' 23 | 24 | class B(A): 25 | NAME = 'b' 26 | 27 | class C(B): 28 | NAME = 'c' 29 | 30 | expected = { 31 | 'a': A, 32 | 'b': B, 33 | 'c': C, 34 | } 35 | actual = lookup.make_lookup_table(A, 'NAME') 36 | self.assertEqual(expected, actual) 37 | -------------------------------------------------------------------------------- /imapautofiler/config.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import logging 14 | import os.path 15 | 16 | import yaml 17 | 18 | LOG = logging.getLogger(__name__) 19 | 20 | 21 | def get_config(filename): 22 | """Return the configuration data. 23 | 24 | :param filename: name of configuration file to read 25 | :type filename: str 26 | 27 | Read ``filename`` and parse it as a YAML file, then return the 28 | results. 29 | 30 | """ 31 | filename = os.path.expanduser(filename) 32 | LOG.debug('loading config from %s', filename) 33 | with open(filename, 'r', encoding='utf-8') as f: 34 | return yaml.load(f) 35 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = imapautofiler 3 | author = Doug Hellmann 4 | author-email = doug@doughellmann.com 5 | summary = Automatically file IMAP messages 6 | home-page = https://github.com/dhellmann/imapautofiler 7 | description-file = 8 | README.rst 9 | classifier = 10 | Development Status :: 5 - Production/Stable 11 | Intended Audience :: Information Technology 12 | License :: OSI Approved :: Apache Software License 13 | Operating System :: OS Independent 14 | Programming Language :: Python 15 | Programming Language :: Python :: 3 16 | Programming Language :: Python :: 3.5 17 | Topic :: Communications :: Email 18 | 19 | [files] 20 | packages = 21 | imapautofiler 22 | 23 | [extras] 24 | test = 25 | coverage 26 | pytest 27 | pytest-cov 28 | testtools 29 | fixtures 30 | flake8 31 | docs = 32 | Sphinx 33 | 34 | [global] 35 | setup-hooks = 36 | pbr.hooks.setup_hook 37 | 38 | [entry_points] 39 | console_scripts = 40 | imapautofiler = imapautofiler.app:main 41 | 42 | [build_sphinx] 43 | all-files = 1 44 | warning-is-error = 1 45 | source-dir = doc/source 46 | build-dir = doc/build 47 | 48 | [pbr] 49 | autodoc_index_modules = True 50 | api_doc_dir = api 51 | autodoc_exclude_modules = 52 | imapautofiler.tests.* 53 | 54 | [wheel] 55 | universal = 1 -------------------------------------------------------------------------------- /tools/maildir_test_data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import argparse 16 | import os 17 | 18 | from imapautofiler.tests import test_maildir 19 | 20 | 21 | def main(): 22 | parser = argparse.ArgumentParser() 23 | parser.add_argument( 24 | 'maildir_root', 25 | help='local directory for the Maildir folders', 26 | ) 27 | args = parser.parse_args() 28 | 29 | if not os.path.exists(args.maildir_root): 30 | os.makedirs(args.maildir_root) 31 | 32 | src = test_maildir.MaildirFixture( 33 | args.maildir_root, 34 | 'source-maildir', 35 | ) 36 | src.make_message( 37 | subject='test subject', 38 | to_addr='pyatl-list@meetup.com', 39 | ) 40 | 41 | test_maildir.MaildirFixture( 42 | args.maildir_root, 43 | 'destination-maildir', 44 | ) 45 | 46 | 47 | if __name__ == '__main__': 48 | main() 49 | -------------------------------------------------------------------------------- /doc/source/running.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Running 3 | ========= 4 | 5 | Run ``imapautofiler`` on the command line. 6 | 7 | .. code-block:: text 8 | 9 | $ imapautofiler -h 10 | usage: imapautofiler [-h] [-v] [--debug] [-c CONFIG_FILE] [--list-mailboxes] 11 | 12 | optional arguments: 13 | -h, --help show this help message and exit 14 | -v, --verbose report more details about what is happening 15 | --debug turn on imaplib debugging output 16 | -c CONFIG_FILE, --config-file CONFIG_FILE 17 | --list-mailboxes instead of processing rules, print a list of mailboxes 18 | 19 | When run with no arguments, it reads the default configuration file 20 | and processes the rules. 21 | 22 | .. code-block:: text 23 | 24 | $ imapautofiler 25 | Password for my-user@example.com 26 | Trash: 13767 (Re: spam message from disqus comment) to INBOX.Trash 27 | Move: 13771 (Re: [Openstack-operators] [deployment] [oslo] [ansible] [tripleo] [kolla] [helm] Configuration management with etcd / confd) to INBOX.OpenStack.Misc Lists 28 | imapautofiler: encountered 10 messages, processed 2 29 | 30 | 31 | Different IMAP servers may use different naming conventions for 32 | mailbox hierarchies. Use the ``--list-mailboxes`` option to the 33 | command line program to print a list of all of the mailboxes known to 34 | the account. 35 | 36 | .. code-block:: text 37 | 38 | $ imapautofiler --list-mailboxes 39 | Password for my-user@example.com: 40 | INBOX 41 | INBOX.Archive 42 | INBOX.Conferences.PyCon-Organizers 43 | INBOX.ML.TIP 44 | INBOX.ML.python-announce-list 45 | INBOX.OpenStack.Dev List 46 | INBOX.PSF 47 | INBOX.Personal 48 | INBOX.PyATL 49 | INBOX.Sent Items 50 | INBOX.Sent Messages 51 | INBOX.Trash 52 | INBOX.Work 53 | -------------------------------------------------------------------------------- /imapautofiler/secrets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import getpass 3 | 4 | import keyring 5 | 6 | LOG = logging.getLogger('imapautofiler.client') 7 | 8 | 9 | class FixedPasswordSecret: 10 | def __init__(self, password): 11 | self.password = password 12 | 13 | def get_password(self): 14 | return self.password 15 | 16 | 17 | class KeyringPasswordSecret: 18 | 19 | def __init__(self, hostname, username): 20 | self.hostname = hostname 21 | self.username = username 22 | 23 | def get_password(self): 24 | password = keyring.get_password(self.hostname, self.username) 25 | if not password: 26 | LOG.debug("No keyring password; getting one interactively") 27 | password = getpass.getpass( 28 | 'Password for {} (will be stored in the system keyring):' 29 | .format(self.username) 30 | ) 31 | keyring.set_password(self.hostname, self.username, password) 32 | 33 | return keyring.get_password(self.hostname, self.username) 34 | 35 | 36 | class AskPassword: 37 | 38 | def __init__(self, hostname, username): 39 | self.hostname = hostname 40 | self.username = username 41 | 42 | def get_password(self): 43 | return getpass.getpass('Password for {}:'.format(self.username)) 44 | 45 | 46 | def configure_providers(cfg): 47 | # First, we'll try for the in-config one. It's not recommended, but someone 48 | # may have set it. 49 | try: 50 | provider = FixedPasswordSecret(cfg['server']['password']) 51 | LOG.debug("Password provider in config as cleartext") 52 | except KeyError: 53 | pass 54 | else: 55 | yield provider 56 | 57 | # Second, we will try for a keyring password if configured 58 | try: 59 | use_keyring = cfg['server']['use_keyring'] 60 | except KeyError: 61 | use_keyring = False 62 | 63 | if use_keyring: 64 | LOG.debug("Password configured from keyring") 65 | yield KeyringPasswordSecret( 66 | hostname=cfg['server']['hostname'], 67 | username=cfg['server']['username'], 68 | ) 69 | 70 | else: 71 | yield AskPassword( 72 | hostname=cfg['server']['hostname'], 73 | username=cfg['server']['username'], 74 | ) 75 | 76 | 77 | def get_password(cfg): 78 | for provider in configure_providers(cfg): 79 | password = provider.get_password() 80 | if password: 81 | return password 82 | -------------------------------------------------------------------------------- /doc/source/contributing.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Contributing 3 | ============== 4 | 5 | .. include:: ../../CONTRIBUTING.rst 6 | 7 | Message handling rules 8 | ====================== 9 | 10 | imapautofiler operation is driven by *rules* and *actions* defined in 11 | the :doc:`configuration file `. 12 | 13 | Each rule is evaluated by a class derived from 14 | :class:`~imapautofiler.rules.Rule` and implemented in 15 | ``imapautofiler/rules.py``. 16 | 17 | A rule must implement the ``check()`` method to support the interface 18 | defined by the abstract base class. The method receives an 19 | `email.message.Message 20 | `__ instance 21 | containing the message being processed. ``check()`` must return 22 | ``True`` if the rule should be applied to the message, or ``False`` if 23 | it should not. 24 | 25 | Each new rule must be handled in the 26 | :func:`~imapautofiler.rules.factory` function so that when the name of 27 | the rule is encountered the correct rule class is instantiated and 28 | returned. 29 | 30 | Message handling actions 31 | ======================== 32 | 33 | Each action triggered by a rule is evaluated by a class derived from 34 | :class:`~imapautofiler.actions.Action` and implemented in 35 | ``imapautofiler/actions.py``. 36 | 37 | An action must implement the ``invoke()`` method to support the 38 | interface defined by the abstract base class. The method receives an 39 | ``IMAPClient`` instance (from the `imapclient`_ package) connected to 40 | the IMAP server being scanned, a string message ID, and an 41 | `email.message.Message 42 | `__ instance 43 | containing the message being processed. ``invoke()`` must perform the 44 | relevant operation on the message. 45 | 46 | Each new action must be handled in the 47 | :func:`~imapautofiler.actions.factory` function so that when the name 48 | of the action is encountered the correct action class is instantiated 49 | and returned. 50 | 51 | .. _imapclient: http://imapclient.readthedocs.io/en/stable/ 52 | 53 | API Documentation 54 | ================= 55 | 56 | .. toctree:: 57 | 58 | api/autoindex 59 | 60 | Local Test Maildir 61 | ================== 62 | 63 | Use ``tools/maildir_test_data.py`` to create a local test Maildir with 64 | a few sample messages. The script requires several dependencies, so 65 | for convenience there is a tox environment pre-configured to run it in 66 | a virtualenv. 67 | 68 | The script requires one argument to indicate the parent directory 69 | where the Maildirs should be created. 70 | 71 | .. code-block:: console 72 | 73 | $ tox -e testdata -- /tmp/testdata 74 | -------------------------------------------------------------------------------- /imapautofiler/tests/test_secrets.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import unittest 14 | import unittest.mock as mock 15 | 16 | from imapautofiler import secrets 17 | 18 | 19 | class TestFixedPassword(unittest.TestCase): 20 | 21 | def test_get_password(self): 22 | self.assertEqual( 23 | mock.sentinel.Password, 24 | secrets.FixedPasswordSecret(mock.sentinel.Password).get_password() 25 | ) 26 | 27 | 28 | class TestInteractivePassword(unittest.TestCase): 29 | 30 | def test_returns_getpass(self): 31 | with mock.patch('getpass.getpass') as getpass: 32 | self.assertEqual( 33 | getpass.return_value, 34 | secrets.AskPassword(None, None).get_password() 35 | ) 36 | 37 | 38 | class TestKeyringPassword(unittest.TestCase): 39 | 40 | def setUp(self): 41 | self.fixture = secrets.KeyringPasswordSecret('hostname', 'username') 42 | patcher = mock.patch('getpass.getpass') 43 | self.getpass = patcher.start() 44 | self.addCleanup(patcher.stop) 45 | patcher = mock.patch('keyring.get_password') 46 | self.get_password = patcher.start() 47 | self.addCleanup(patcher.stop) 48 | patcher = mock.patch('keyring.set_password') 49 | self.set_password = patcher.start() 50 | self.addCleanup(patcher.stop) 51 | 52 | def test_get_password_in_keychain(self): 53 | self.get_password.return_value = mock.sentinel.Password 54 | 55 | self.assertEqual( 56 | mock.sentinel.Password, 57 | self.fixture.get_password() 58 | ) 59 | 60 | def test_get_password_missing_sets_password(self): 61 | self.get_password.side_effect = [None, mock.sentinel.Password] 62 | self.assertEqual( 63 | mock.sentinel.Password, 64 | self.fixture.get_password() 65 | ) 66 | 67 | self.set_password.assert_called_once_with( 68 | 'hostname', 'username', self.getpass.return_value 69 | ) 70 | 71 | 72 | class TestGetSecretFromConfig(unittest.TestCase): 73 | 74 | def configured(self, **server_args): 75 | server_args.setdefault('hostname', 'hostname') 76 | server_args.setdefault('username', 'username') 77 | return list(secrets.configure_providers({'server': server_args})) 78 | 79 | def test_configure_providers_has_fixed_when_config_has_password(self): 80 | self.assertIsInstance( 81 | self.configured(password='a password')[0], 82 | secrets.FixedPasswordSecret 83 | ) 84 | 85 | def test_configure_providers_includes_keyring_with_keyring_enabled(self): 86 | ps = self.configured(use_keyring=True) 87 | self.assertTrue( 88 | any(isinstance(p, secrets.KeyringPasswordSecret) for p in ps) 89 | ) 90 | 91 | def test_configure_providers_excludes_getpass_with_keyring_enabled(self): 92 | providers = self.configured(use_keyring=True) 93 | self.assertFalse( 94 | any(isinstance(p, secrets.AskPassword) for p in providers) 95 | ) 96 | -------------------------------------------------------------------------------- /imapautofiler/tests/base.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import email.parser 14 | import logging 15 | from email.header import Header 16 | from email.message import Message 17 | import fixtures 18 | import testtools 19 | from email.utils import format_datetime 20 | import datetime 21 | 22 | 23 | def construct_message(headers): 24 | msg = Message() 25 | encoding = 'utf-8' 26 | 27 | for header, value in headers.items(): 28 | msg[header] = Header(value, encoding) 29 | 30 | return msg.as_string() 31 | 32 | 33 | date = format_datetime(datetime.datetime.now()) 34 | past_date = format_datetime( 35 | datetime.datetime.now() - datetime.timedelta(days=90) 36 | ) 37 | MESSAGE = { 38 | 'From': 'Sender Name ', 39 | 'Content-Type': 40 | 'multipart/alternative; ' 41 | 'boundary="Apple-Mail=_F10D7C06-52F7-4F60-BEC9-4D5F29A9BFE1"', 42 | 'Message-Id': '<4FF56508-357B-4E73-82DE-458D3EEB2753@example.com>', 43 | 'Mime-Version': '1.0 (Mac OS X Mail 9.2 \(3112\))', 44 | 'X-Smtp-Server': 'AE35BF63-D70A-4AB0-9FAA-3F18EB9802A9', 45 | 'Subject': 'Re: reply to previous message', 46 | 'Date': '{}'.format(past_date), 47 | 'X-Universally-Unique-Identifier': 'CC844EE1-C406-4ABA-9DA5-685759BBC15A', 48 | 'References': '<33509d2c-e2a7-48c0-8bf3-73b4ba352b2f@example.com>', 49 | 'To': 'recipient1@example.com', 50 | 'CC': 'recipient2@example.com', 51 | 'In-Reply-To': '<33509d2c-e2a7-48c0-8bf3-73b4ba352b2f@example.com>' 52 | 53 | } 54 | 55 | I18N_MESSAGE = MESSAGE.copy() 56 | I18N_MESSAGE.update({ 57 | 'From': 'Иванов Иван ', 58 | 'To': 'Иванов Иван ', 59 | 'Subject': 'Re: ответ на предыдущее сообщение', 60 | }) 61 | 62 | RECENT_MESSAGE = MESSAGE.copy() 63 | RECENT_MESSAGE.update({ 64 | 'Date': '{}'.format(date), 65 | }) 66 | 67 | 68 | class TestCase(testtools.TestCase): 69 | _msg = None 70 | _recent_msg = None 71 | _i18n_msg = None 72 | 73 | def setUp(self): 74 | super().setUp() 75 | # Capture logging 76 | self.useFixture(fixtures.FakeLogger(level=logging.DEBUG)) 77 | # Capturing printing 78 | stdout = self.useFixture(fixtures.StringStream('stdout')).stream 79 | self.useFixture(fixtures.MonkeyPatch('sys.stdout', stdout)) 80 | 81 | @property 82 | def msg(self): 83 | if self._msg is None: 84 | self._msg = email.parser.Parser().parsestr( 85 | construct_message(MESSAGE) 86 | ) 87 | return self._msg 88 | 89 | @property 90 | def i18n_msg(self): 91 | if self._i18n_msg is None: 92 | self._i18n_msg = email.parser.Parser().parsestr( 93 | construct_message(I18N_MESSAGE) 94 | ) 95 | return self._i18n_msg 96 | 97 | @property 98 | def recent_msg(self): 99 | if self._recent_msg is None: 100 | self._recent_msg = email.parser.Parser().parsestr( 101 | construct_message(RECENT_MESSAGE) 102 | ) 103 | return self._recent_msg 104 | 105 | 106 | def pytest_generate_tests(metafunc): 107 | # from https://docs.pytest.org/en/latest/example/parametrize.html#a-quick-port-of-testscenarios # noqa 108 | idlist = [] 109 | argvalues = [] 110 | for scenario in metafunc.cls.scenarios: 111 | idlist.append(scenario[0]) 112 | items = scenario[1].items() 113 | argnames = [x[0] for x in items] 114 | argvalues.append(([x[1] for x in items])) 115 | metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class") 116 | -------------------------------------------------------------------------------- /imapautofiler/tests/test_maildir.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import email.utils 14 | import mailbox 15 | import os.path 16 | import textwrap 17 | import testtools 18 | 19 | import fixtures 20 | 21 | from imapautofiler import client 22 | 23 | 24 | class MaildirFixture(fixtures.Fixture): 25 | 26 | def __init__(self, root, name): 27 | self._root = root 28 | self._path = os.path.join(root, name) 29 | self.name = name 30 | self.make_message(subject='init maildir') 31 | 32 | def make_message( 33 | self, 34 | subject='subject', 35 | from_addr='from@example.com', 36 | to_addr='to@example.com'): 37 | mbox = mailbox.Maildir(self._path) 38 | mbox.lock() 39 | try: 40 | msg = mailbox.MaildirMessage() 41 | msg.set_unixfrom('author Sat Jul 23 15:35:34 2017') 42 | msg['From'] = from_addr 43 | msg['To'] = to_addr 44 | msg['Subject'] = subject 45 | msg['Date'] = email.utils.formatdate() 46 | msg.set_payload(textwrap.dedent(''' 47 | This is the body. 48 | There are 2 lines. 49 | ''')) 50 | mbox.add(msg) 51 | mbox.flush() 52 | finally: 53 | mbox.unlock() 54 | return msg 55 | 56 | 57 | class MaildirTest(testtools.TestCase): 58 | 59 | def setUp(self): 60 | super().setUp() 61 | self.tmpdir = self.useFixture(fixtures.TempDir()).path 62 | self.src_mbox = self.useFixture( 63 | MaildirFixture(self.tmpdir, 'source-mailbox') 64 | ) 65 | # self.msg = self.src_mbox.make_message() 66 | self.dest_mbox = self.useFixture( 67 | MaildirFixture(self.tmpdir, 'destination-mailbox') 68 | ) 69 | self.client = client.MaildirClient({'maildir': self.tmpdir}) 70 | 71 | def test_list_mailboxes(self): 72 | expected = set(['destination-mailbox', 'source-mailbox']) 73 | actual = set(self.client.list_mailboxes()) 74 | self.assertEqual(expected, actual) 75 | 76 | def test_mailbox_iterate(self): 77 | self.src_mbox.make_message(subject='added by test') 78 | expected = set(['init maildir', 'added by test']) 79 | actual = set( 80 | msg['subject'] 81 | for msg_id, msg in self.client.mailbox_iterate(self.src_mbox.name) 82 | ) 83 | self.assertEqual(expected, actual) 84 | 85 | def test_copy_message(self): 86 | self.src_mbox.make_message(subject='added by test') 87 | messages = list(self.client.mailbox_iterate(self.src_mbox.name)) 88 | for msg_id, msg in messages: 89 | if msg['subject'] != 'added by test': 90 | continue 91 | self.client.copy_message( 92 | self.src_mbox.name, 93 | self.dest_mbox.name, 94 | msg_id, 95 | msg, 96 | ) 97 | expected = set(['init maildir', 'added by test']) 98 | actual = set( 99 | msg['subject'] 100 | for msg_id, msg in self.client.mailbox_iterate(self.dest_mbox.name) 101 | ) 102 | self.assertEqual(expected, actual) 103 | 104 | def test_move_message(self): 105 | self.src_mbox.make_message(subject='added by test') 106 | messages = list(self.client.mailbox_iterate(self.src_mbox.name)) 107 | for msg_id, msg in messages: 108 | if msg['subject'] != 'added by test': 109 | continue 110 | self.client.move_message( 111 | self.src_mbox.name, 112 | self.dest_mbox.name, 113 | msg_id, 114 | msg, 115 | ) 116 | expected = set(['init maildir', 'added by test']) 117 | actual = set( 118 | msg['subject'] 119 | for msg_id, msg in self.client.mailbox_iterate(self.dest_mbox.name) 120 | ) 121 | self.assertEqual(expected, actual) 122 | # No longer appears in source maildir 123 | expected = set(['init maildir']) 124 | actual = set( 125 | msg['subject'] 126 | for msg_id, msg in self.client.mailbox_iterate(self.src_mbox.name) 127 | ) 128 | self.assertEqual(expected, actual) 129 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # imapautofiler documentation build configuration file, created by 5 | # sphinx-quickstart on Mon Jun 5 19:46:09 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | import subprocess 24 | 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # The suffix(es) of source filenames. 43 | # You can specify multiple suffix as a list of string: 44 | # 45 | # source_suffix = ['.rst', '.md'] 46 | source_suffix = '.rst' 47 | 48 | # The master toctree document. 49 | master_doc = 'index' 50 | 51 | # General information about the project. 52 | project = 'imapautofiler' 53 | copyright = '2017, Doug Hellmann' 54 | author = 'Doug Hellmann' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | #version = '1.1.0' 62 | try: 63 | version = subprocess.check_output(['git', 'describe']).decode('utf-8') 64 | except Exception: 65 | version = 'unknown' 66 | # The full version, including alpha/beta/rc tags. 67 | release = version 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = [] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = 'sphinx' 83 | 84 | # If true, `todo` and `todoList` produce output, else they produce nothing. 85 | todo_include_todos = False 86 | 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = 'default' 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ['_static'] 105 | 106 | 107 | # -- Options for HTMLHelp output ------------------------------------------ 108 | 109 | # Output file base name for HTML help builder. 110 | htmlhelp_basename = 'imapautofilerdoc' 111 | 112 | 113 | # -- Options for LaTeX output --------------------------------------------- 114 | 115 | latex_elements = { 116 | # The paper size ('letterpaper' or 'a4paper'). 117 | # 118 | # 'papersize': 'letterpaper', 119 | 120 | # The font size ('10pt', '11pt' or '12pt'). 121 | # 122 | # 'pointsize': '10pt', 123 | 124 | # Additional stuff for the LaTeX preamble. 125 | # 126 | # 'preamble': '', 127 | 128 | # Latex figure (float) alignment 129 | # 130 | # 'figure_align': 'htbp', 131 | } 132 | 133 | # Grouping the document tree into LaTeX files. List of tuples 134 | # (source start file, target name, title, 135 | # author, documentclass [howto, manual, or own class]). 136 | latex_documents = [ 137 | (master_doc, 'imapautofiler.tex', 'imapautofiler Documentation', 138 | 'Doug Hellmann', 'manual'), 139 | ] 140 | 141 | 142 | # -- Options for manual page output --------------------------------------- 143 | 144 | # One entry per manual page. List of tuples 145 | # (source start file, name, description, authors, manual section). 146 | man_pages = [ 147 | (master_doc, 'imapautofiler', 'imapautofiler Documentation', 148 | [author], 1) 149 | ] 150 | 151 | 152 | # -- Options for Texinfo output ------------------------------------------- 153 | 154 | # Grouping the document tree into Texinfo files. List of tuples 155 | # (source start file, target name, title, author, 156 | # dir menu entry, description, category) 157 | texinfo_documents = [ 158 | (master_doc, 'imapautofiler', 'imapautofiler Documentation', 159 | author, 'imapautofiler', 'One line description of project.', 160 | 'Miscellaneous'), 161 | ] 162 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /imapautofiler/app.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | """ 14 | """ 15 | 16 | import argparse 17 | import imaplib 18 | import logging 19 | import sys 20 | 21 | from imapautofiler import actions 22 | from imapautofiler import client 23 | from imapautofiler import config 24 | from imapautofiler import rules 25 | 26 | LOG = logging.getLogger('imapautofiler') 27 | 28 | 29 | def list_mailboxes(cfg, debug, conn): 30 | """Print a list of the mailboxes. 31 | 32 | :param cfg: full configuration 33 | :type cfg: dict 34 | :param debug: flag to control debug output 35 | :type debug: bool 36 | :param conn: IMAP server onnection 37 | :type conn: imapautofiler.client.Client 38 | 39 | Used by the ``--list-mailboxes`` switch. 40 | 41 | """ 42 | for f in conn.list_mailboxes(): 43 | print(f) 44 | 45 | 46 | def process_rules(cfg, debug, conn, dry_run=False): 47 | """Run the rules from the configuration file. 48 | 49 | :param cfg: full configuration 50 | :type cfg: dict 51 | :param debug: flag to control debug output 52 | :type debug: bool 53 | :param conn: IMAP server onnection 54 | :type conn: imapautofiler.client.Client 55 | 56 | """ 57 | num_messages = 0 58 | num_processed = 0 59 | num_errors = 0 60 | 61 | for mailbox in cfg['mailboxes']: 62 | mailbox_name = mailbox['name'] 63 | 64 | mailbox_rules = [ 65 | rules.factory(r, cfg) 66 | for r in mailbox['rules'] 67 | ] 68 | 69 | for (msg_id, message) in conn.mailbox_iterate(mailbox_name): 70 | num_messages += 1 71 | if debug: 72 | print(message.as_string().rstrip()) 73 | else: 74 | LOG.debug('message %s: %s', msg_id, message['subject']) 75 | 76 | for rule in mailbox_rules: 77 | if rule.check(message): 78 | action = actions.factory(rule.get_action(), cfg) 79 | try: 80 | action.report(conn, mailbox_name, msg_id, message) 81 | if not dry_run: 82 | action.invoke(conn, mailbox_name, msg_id, 83 | message) 84 | except Exception as err: 85 | LOG.error('failed to %s "%s": %s', 86 | action.NAME, 87 | message['subject'], err) 88 | num_errors += 1 89 | else: 90 | num_processed += 1 91 | # At this point we've processed the message 92 | # based on one rule, so there is no need to 93 | # look at the other rules. 94 | break 95 | else: 96 | LOG.debug('no rules match') 97 | 98 | # break 99 | 100 | # Remove messages that we just moved. 101 | conn.expunge() 102 | LOG.info('encountered %s messages, processed %s', 103 | num_messages, num_processed) 104 | if num_errors: 105 | LOG.info('encountered %d errors', num_errors) 106 | return 107 | 108 | 109 | def main(args=None): 110 | parser = argparse.ArgumentParser() 111 | parser.add_argument( 112 | '-v', '--verbose', 113 | action='store_true', 114 | default=False, 115 | help='report more details about what is happening', 116 | ) 117 | parser.add_argument( 118 | '--debug', 119 | action='store_true', 120 | default=False, 121 | help='turn on imaplib debugging output', 122 | ) 123 | parser.add_argument( 124 | '-c', '--config-file', 125 | default='~/.imapautofiler.yml', 126 | ) 127 | parser.add_argument( 128 | '--list-mailboxes', 129 | default=False, 130 | action='store_true', 131 | help='instead of processing rules, print a list of mailboxes', 132 | ) 133 | parser.add_argument( 134 | '-n', '--dry-run', 135 | default=False, 136 | action='store_true', 137 | help='process the rules without taking any action', 138 | ) 139 | args = parser.parse_args() 140 | 141 | if args.debug: 142 | imaplib.Debug = 4 143 | 144 | if args.verbose or args.debug: 145 | log_level = logging.DEBUG 146 | else: 147 | log_level = logging.INFO 148 | 149 | logging.basicConfig( 150 | level=log_level, 151 | format='%(name)s: %(message)s', 152 | ) 153 | logging.debug('starting') 154 | 155 | try: 156 | cfg = config.get_config(args.config_file) 157 | conn = client.open_connection(cfg) 158 | try: 159 | if args.list_mailboxes: 160 | list_mailboxes(cfg, args.debug, conn) 161 | else: 162 | process_rules(cfg, args.debug, conn, args.dry_run) 163 | finally: 164 | conn.close() 165 | except Exception as err: 166 | if args.debug: 167 | raise 168 | parser.error(err) 169 | return 0 170 | 171 | 172 | if __name__ == '__main__': 173 | sys.exit(main()) 174 | -------------------------------------------------------------------------------- /imapautofiler/client.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | """Mail client API. 14 | """ 15 | 16 | import abc 17 | import contextlib 18 | import email.parser 19 | import logging 20 | import mailbox 21 | import os 22 | import ssl 23 | 24 | import imapclient 25 | 26 | from . import secrets 27 | 28 | LOG = logging.getLogger('imapautofiler.client') 29 | 30 | 31 | def open_connection(cfg): 32 | "Open a connection to the mail server." 33 | if 'server' in cfg: 34 | return IMAPClient(cfg) 35 | if 'maildir' in cfg: 36 | return MaildirClient(cfg) 37 | raise ValueError('Could not find connection information in config') 38 | 39 | 40 | class Client(metaclass=abc.ABCMeta): 41 | 42 | def __init__(self, cfg): 43 | self._cfg = cfg 44 | 45 | @abc.abstractmethod 46 | def list_mailboxes(self): 47 | "Return a list of mailbox names." 48 | 49 | @abc.abstractmethod 50 | def mailbox_iterate(self, mailbox_name): 51 | """Iterate over messages from the mailbox. 52 | 53 | Produces tuples of (message_id, message). 54 | """ 55 | 56 | @abc.abstractmethod 57 | def copy_message(self, src_mailbox, dest_mailbox, message_id, message): 58 | """Create a copy of the message in the destination mailbox. 59 | 60 | :param src_mailbox: name of the source mailbox 61 | :type src_mailbox: str 62 | :param dest_mailbox: name of the destination mailbox 63 | :type dest_mailbox: src 64 | :param message_id: ID of the message to copy 65 | :type message_id: str 66 | :param message: message instance 67 | :type message: email.message.Message 68 | 69 | """ 70 | 71 | def move_message(self, src_mailbox, dest_mailbox, message_id, message): 72 | """Move the message from the source to the destination mailbox. 73 | 74 | :param src_mailbox: name of the source mailbox 75 | :type src_mailbox: str 76 | :param dest_mailbox: name of the destination mailbox 77 | :type dest_mailbox: src 78 | :param message_id: ID of the message to copy 79 | :type message_id: str 80 | :param message: message instance 81 | :type message: email.message.Message 82 | 83 | """ 84 | self.copy_message( 85 | src_mailbox, 86 | dest_mailbox, 87 | message_id, 88 | message, 89 | ) 90 | self.delete_message( 91 | src_mailbox, 92 | message_id, 93 | message, 94 | ) 95 | 96 | @abc.abstractmethod 97 | def delete_message(self, src_mailbox, message_id, message): 98 | """Remove the message. 99 | 100 | :param src_mailbox: name of the source mailbox 101 | :type src_mailbox: str 102 | :param message_id: ID of the message to copy 103 | :type message_id: str 104 | :param message: message instance 105 | :type message: email.message.Message 106 | 107 | """ 108 | 109 | @abc.abstractmethod 110 | def expunge(self): 111 | "Flush any pending changes." 112 | 113 | @abc.abstractmethod 114 | def close(self): 115 | "Close the connection, flushing any pending changes." 116 | 117 | 118 | class IMAPClient(Client): 119 | 120 | def __init__(self, cfg): 121 | super().__init__(cfg) 122 | 123 | # Use default client behavior if ca_file not provided. 124 | context = None 125 | if 'ca_file' in cfg['server']: 126 | context = ssl.create_default_context( 127 | cafile=cfg['server']['ca_file'] 128 | ) 129 | 130 | self._conn = imapclient.IMAPClient( 131 | cfg['server']['hostname'], 132 | use_uid=True, 133 | ssl=True, 134 | port=cfg['server'].get('port'), 135 | ssl_context=context, 136 | ) 137 | username = cfg['server']['username'] 138 | password = secrets.get_password(cfg) 139 | self._conn.login(username, password) 140 | 141 | def list_mailboxes(self): 142 | "Return a list of folder names." 143 | return ( 144 | f[-1] 145 | for f in self._conn.list_folders() 146 | ) 147 | 148 | def mailbox_iterate(self, mailbox_name): 149 | self._conn.select_folder(mailbox_name) 150 | msg_ids = self._conn.search(['ALL']) 151 | for msg_id in msg_ids: 152 | email_parser = email.parser.BytesFeedParser() 153 | response = self._conn.fetch([msg_id], ['BODY.PEEK[HEADER]']) 154 | email_parser.feed(response[msg_id][b'BODY[HEADER]']) 155 | message = email_parser.close() 156 | yield (msg_id, message) 157 | 158 | def copy_message(self, src_mailbox, dest_mailbox, message_id, message): 159 | self._conn.copy([message_id], dest_mailbox) 160 | 161 | def delete_message(self, src_mailbox, message_id, message): 162 | self._conn.add_flags([message_id], [imapclient.DELETED]) 163 | 164 | def expunge(self): 165 | self._conn.expunge() 166 | 167 | def close(self): 168 | try: 169 | self._conn.close() 170 | except Exception: 171 | pass 172 | self._conn.logout() 173 | 174 | 175 | class MaildirClient(Client): 176 | 177 | def __init__(self, cfg): 178 | super().__init__(cfg) 179 | self._root = os.path.expanduser(cfg['maildir']) 180 | LOG.debug('maildir: %s', self._root) 181 | 182 | @contextlib.contextmanager 183 | def _locked(self, mailbox_name): 184 | path = os.path.join(self._root, mailbox_name) 185 | LOG.debug('locking %s', path) 186 | box = mailbox.Maildir(path) 187 | box.lock() 188 | try: 189 | yield box 190 | finally: 191 | box.flush() 192 | box.close() 193 | 194 | def list_mailboxes(self): 195 | # NOTE: We don't use Maildir to open root because it is a 196 | # parent directory but not a maildir. 197 | return sorted(os.listdir(self._root)) 198 | 199 | def mailbox_iterate(self, mailbox_name): 200 | with self._locked(mailbox_name) as box: 201 | results = list(box.iteritems()) 202 | return results 203 | 204 | def copy_message(self, src_mailbox, dest_mailbox, message_id, message): 205 | with self._locked(dest_mailbox) as box: 206 | box.add(message) 207 | 208 | def delete_message(self, src_mailbox, message_id, message): 209 | with self._locked(src_mailbox) as box: 210 | box.remove(message_id) 211 | 212 | def expunge(self): 213 | pass 214 | 215 | def close(self): 216 | pass 217 | -------------------------------------------------------------------------------- /imapautofiler/rules.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import abc 14 | import logging 15 | import re 16 | from email.utils import parsedate_to_datetime 17 | from datetime import datetime, timedelta 18 | from imapautofiler import i18n 19 | from imapautofiler import lookup 20 | 21 | 22 | class Rule(metaclass=abc.ABCMeta): 23 | "Base class" 24 | 25 | _log = logging.getLogger(__name__) 26 | 27 | NAME = None 28 | 29 | def __init__(self, rule_data, cfg): 30 | """Initialize the rule. 31 | 32 | :param rule_data: data describing the rule 33 | :type rule_data: dict 34 | :param cfg: full configuration data 35 | :type cfg: dict 36 | 37 | """ 38 | self._log.debug('rule %r', rule_data) 39 | self._data = rule_data 40 | self._cfg = cfg 41 | 42 | @abc.abstractmethod 43 | def check(self, message): 44 | """Test the rule on the message. 45 | 46 | :param conn: connection to IMAP server 47 | :type conn: imapclient.IMAPClient 48 | :param message: the message object to process 49 | :type message: email.message.Message 50 | 51 | """ 52 | raise NotImplementedError() 53 | 54 | def get_action(self): 55 | return self._data.get('action', {}) 56 | 57 | 58 | class Or(Rule): 59 | """True if any one of the sub-rules is true. 60 | 61 | The rule data must contain a ``rules`` list with other rules 62 | specifications. 63 | 64 | Actions on the sub-rules are ignored. 65 | 66 | """ 67 | 68 | NAME = 'or' 69 | _log = logging.getLogger(NAME) 70 | 71 | def __init__(self, rule_data, cfg): 72 | super().__init__(rule_data, cfg) 73 | self._sub_rules = [ 74 | factory(r, cfg) 75 | for r in rule_data['or'].get('rules', []) 76 | ] 77 | 78 | def check(self, message): 79 | if not self._sub_rules: 80 | self._log.debug('no sub-rules') 81 | return False 82 | return any(r.check(message) for r in self._sub_rules) 83 | 84 | 85 | class And(Rule): 86 | """True if all of the sub-rules are true. 87 | 88 | The rule data must contain a ``rules`` list with other rules 89 | specifications. 90 | 91 | Actions on the sub-rules are ignored. 92 | 93 | """ 94 | 95 | NAME = 'and' 96 | _log = logging.getLogger(NAME) 97 | 98 | def __init__(self, rule_data, cfg): 99 | super().__init__(rule_data, cfg) 100 | self._sub_rules = [ 101 | factory(r, cfg) 102 | for r in rule_data['and'].get('rules', []) 103 | ] 104 | 105 | def check(self, message): 106 | if not self._sub_rules: 107 | self._log.debug('no sub-rules') 108 | return False 109 | return all(r.check(message) for r in self._sub_rules) 110 | 111 | 112 | class Recipient(Or): 113 | """True if any recipient sub-rule matches. 114 | 115 | The rule data must contain a ``recipient`` mapping containing 116 | either a ``substring`` key mapped to a simple string or a 117 | ``regex`` key mapped to a regular expression. 118 | 119 | """ 120 | 121 | NAME = 'recipient' 122 | _log = logging.getLogger(NAME) 123 | 124 | def __init__(self, rule_data, cfg): 125 | rules = [] 126 | for header in ['to', 'cc']: 127 | header_data = {} 128 | header_data.update(rule_data['recipient']) 129 | header_data['name'] = header 130 | rules.append({'headers': [header_data]}) 131 | rule_data['or'] = { 132 | 'rules': rules, 133 | } 134 | super().__init__(rule_data, cfg) 135 | 136 | 137 | class Headers(Rule): 138 | """True if all of the headers match. 139 | 140 | The rule data must contain a ``headers`` list of mappings 141 | containing a ``name`` for the header itself and either a 142 | ``substring`` key mapped to a simple string or a ``regex`` key 143 | mapped to a regular expression to be matched against the value of 144 | the header. 145 | 146 | """ 147 | 148 | NAME = 'headers' 149 | _log = logging.getLogger(NAME) 150 | 151 | def __init__(self, rule_data, cfg): 152 | super().__init__(rule_data, cfg) 153 | self._matchers = [] 154 | for header in rule_data.get('headers', []): 155 | if 'substring' in header: 156 | self._matchers.append(HeaderSubString(header, cfg)) 157 | elif 'regex' in header: 158 | self._matchers.append(HeaderRegex(header, cfg)) 159 | elif 'value' in header: 160 | self._matchers.append(HeaderExactValue(header, cfg)) 161 | else: 162 | raise ValueError('unknown header matcher {!r}'.format(header)) 163 | 164 | def check(self, message): 165 | if not self._matchers: 166 | self._log.debug('no sub-rules') 167 | return False 168 | return all(m.check(message) for m in self._matchers) 169 | 170 | 171 | class _HeaderMatcher(Rule): 172 | _log = logging.getLogger('header') 173 | NAME = None # matchers cannot be used directly 174 | 175 | def __init__(self, rule_data, cfg): 176 | super().__init__(rule_data, cfg) 177 | self._header_name = rule_data['name'] 178 | self._value = rule_data.get('value', '').lower() 179 | 180 | @abc.abstractmethod 181 | def _check_rule(self, header_value): 182 | pass 183 | 184 | def check(self, message): 185 | header_value = i18n.get_header_value(message, self._header_name) 186 | return self._check_rule(header_value) 187 | 188 | 189 | class HeaderExactValue(_HeaderMatcher): 190 | _log = logging.getLogger('header-exact-value') 191 | 192 | def __init__(self, rule_data, cfg): 193 | super().__init__(rule_data, cfg) 194 | self._header_name = rule_data['name'] 195 | self._value = rule_data.get('value', '').lower() 196 | 197 | def _check_rule(self, header_value): 198 | self._log.debug('%r == %r', self._value, header_value) 199 | return self._value == header_value.lower() 200 | 201 | 202 | class HeaderSubString(_HeaderMatcher): 203 | "Implements substring matching for headers." 204 | 205 | _log = logging.getLogger('header-substring') 206 | 207 | def __init__(self, rule_data, cfg): 208 | super().__init__(rule_data, cfg) 209 | self._value = rule_data.get('substring', '') 210 | 211 | def _check_rule(self, header_value): 212 | self._log.debug('%r in %r', self._value, header_value) 213 | return self._value in header_value.lower() 214 | 215 | 216 | class HeaderRegex(_HeaderMatcher): 217 | "Implements regular expression matching for headers." 218 | 219 | _log = logging.getLogger('header-regex') 220 | 221 | def __init__(self, rule_data, cfg): 222 | super().__init__(rule_data, cfg) 223 | self._value = rule_data.get('regex', '') 224 | self._regex = re.compile(self._value) 225 | 226 | def _check_rule(self, header_value): 227 | self._log.debug('%r matches %r', self._regex, header_value) 228 | return bool(self._regex.search(header_value)) 229 | 230 | 231 | class HeaderExists(Rule): 232 | "Looks for a message to have a given header." 233 | 234 | NAME = 'header-exists' 235 | _log = logging.getLogger(NAME) 236 | 237 | def __init__(self, rule_data, cfg): 238 | super().__init__(rule_data, cfg) 239 | self._header_name = rule_data['name'] 240 | 241 | def check(self, message): 242 | self._log.debug('%r exists', self._header_name) 243 | return self._header_name in message 244 | 245 | 246 | class IsMailingList(HeaderExists): 247 | "Looks for a message to have a given header." 248 | 249 | NAME = 'is-mailing-list' 250 | _log = logging.getLogger(NAME) 251 | 252 | def __init__(self, rule_data, cfg): 253 | if 'name' not in rule_data: 254 | rule_data['name'] = 'list-id' 255 | super().__init__(rule_data, cfg) 256 | 257 | 258 | class TimeLimit(Rule): 259 | """True if message is older than the specified 'age' measured 260 | in number of days.""" 261 | 262 | NAME = 'time-limit' 263 | _log = logging.getLogger(NAME) 264 | 265 | def __init__(self, rule_data, cfg, ): 266 | super().__init__(rule_data, cfg) 267 | self._age = rule_data['time-limit']['age'] 268 | 269 | def check(self, message): 270 | date = parsedate_to_datetime(i18n.get_header_value(message, 'date')) 271 | if self._age: 272 | time_limit = datetime.now() - timedelta(days=self._age) 273 | if date <= time_limit: 274 | return True 275 | else: 276 | return 0 277 | 278 | 279 | _lookup_table = lookup.make_lookup_table(Rule, 'NAME') 280 | 281 | 282 | def factory(rule_data, cfg): 283 | """Create a rule processor. 284 | 285 | :param rule_data: portion of configuration describing the rule 286 | :type rule_data: dict 287 | :param cfg: full configuration data 288 | :type cfg: dict 289 | 290 | Using the rule type, instantiate a rule processor that can check 291 | the rule against a message. 292 | 293 | """ 294 | for key in rule_data: 295 | if key == 'action': 296 | continue 297 | if key in _lookup_table: 298 | return _lookup_table[key](rule_data, cfg) 299 | raise ValueError('Unknown rule type {!r}'.format(rule_data)) 300 | -------------------------------------------------------------------------------- /imapautofiler/actions.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import abc 14 | import logging 15 | import re 16 | 17 | from imapautofiler import i18n 18 | from imapautofiler import lookup 19 | 20 | 21 | class Action(metaclass=abc.ABCMeta): 22 | "Base class" 23 | 24 | _log = logging.getLogger(__name__) 25 | NAME = None 26 | 27 | def __init__(self, action_data, cfg): 28 | """Initialize the action. 29 | 30 | :param action_data: data describing the action 31 | :type action_data: dict 32 | :param cfg: full configuration data 33 | :type cfg: dict 34 | 35 | """ 36 | self._data = action_data 37 | self._cfg = cfg 38 | self._log.debug('new: %r', action_data) 39 | 40 | @abc.abstractmethod 41 | def report(self, conn, mailbox_name, message_id, message): 42 | "Log a message explaining what action will be taken." 43 | 44 | @abc.abstractmethod 45 | def invoke(self, conn, mailbox_name, message_id, message): 46 | """Run the action on the message. 47 | 48 | :param conn: connection to mail server 49 | :type conn: imapautofiler.client.Client 50 | :param mailbox_name: name of the mailbox holding the message 51 | :type mailbox_name: str 52 | :param message_id: ID of the message to process 53 | :type message_id: str 54 | :param message: the message object to process 55 | :type message: email.message.Message 56 | 57 | """ 58 | raise NotImplementedError() 59 | 60 | 61 | class Move(Action): 62 | """Move the message to a different folder. 63 | 64 | The action is indicated with the name ``move``. 65 | 66 | The action data must contain a ``dest-mailbox`` entry with the 67 | name of the destination mailbox. 68 | 69 | """ 70 | 71 | NAME = 'move' 72 | _log = logging.getLogger(NAME) 73 | 74 | def __init__(self, action_data, cfg): 75 | super().__init__(action_data, cfg) 76 | self._dest_mailbox = self._data.get('dest-mailbox') 77 | 78 | def report(self, conn, src_mailbox, message_id, message): 79 | self._log.info( 80 | '%s (%s) to %s', 81 | message_id, i18n.get_header_value(message, 'subject'), 82 | self._dest_mailbox) 83 | 84 | def invoke(self, conn, src_mailbox, message_id, message): 85 | conn.move_message( 86 | src_mailbox, 87 | self._dest_mailbox, 88 | message_id, 89 | message, 90 | ) 91 | 92 | 93 | class Sort(Action): 94 | """Move the message based on parsing a destination from a header. 95 | 96 | The action is indicated with the name ``sort``. 97 | 98 | The action may contain a ``header`` entry to specify the name of 99 | the mail header to examine to find the destination. The default is 100 | to use the ``to`` header. 101 | 102 | The action data may contain a ``dest-mailbox-regex`` entry for 103 | parsing the header value to obtain the destination mailbox 104 | name. If the regex has one match group, that substring will be 105 | used. If the regex has more than one match group, the 106 | ``dest-mailbox-regex-group`` option must specify which group to 107 | use (0-based numerical index). The default pattern is 108 | ``([\w-+]+)@`` to match the first part of an email address. 109 | 110 | The action data must contain a ``dest-mailbox-base`` entry with 111 | the base name of the destination mailbox. The actual mailbox name 112 | will be constructed by appending the value extracted via 113 | ``dest-mailbox-regex`` to the ``dest-mailbox-base`` value. The 114 | ``dest-mailbox-base`` value should contain the mailbox separator 115 | character (usually ``.``) if the desired mailbox is a sub-folder 116 | of the name given. 117 | 118 | """ 119 | 120 | # TODO(dhellmann): Extend this class to support named groups in 121 | # the regex. 122 | 123 | NAME = 'sort' 124 | _log = logging.getLogger(NAME) 125 | _default_header = 'to' 126 | _default_regex = r'([\w+-]+)@' 127 | 128 | def __init__(self, action_data, cfg): 129 | super().__init__(action_data, cfg) 130 | self._header = self._data.get('header', self._default_header) 131 | self._dest_mailbox_base = self._data.get('dest-mailbox-base') 132 | if not self._dest_mailbox_base: 133 | raise ValueError( 134 | 'No dest-mailbox-base given for action {}'.format( 135 | action_data) 136 | ) 137 | self._dest_mailbox_regex = re.compile(self._data.get( 138 | 'dest-mailbox-regex', self._default_regex)) 139 | if not self._dest_mailbox_regex.groups: 140 | raise ValueError( 141 | 'Regex {!r} has no group to select the mailbox ' 142 | 'name portion.'.format(self._dest_mailbox_regex.pattern) 143 | ) 144 | if self._dest_mailbox_regex.groups > 1: 145 | if 'dest-mailbox-regex-group' not in action_data: 146 | raise ValueError( 147 | 'Regex {!r} has multiple groups and the ' 148 | 'action data does not specify the ' 149 | 'dest-mailbox-regex-group to use.'.format( 150 | self._dest_mailbox_regex.pattern) 151 | ) 152 | self._dest_mailbox_regex_group = action_data.get( 153 | 'dest-mailbox-regex-group', 0) 154 | 155 | def _get_dest_mailbox(self, message_id, message): 156 | header_value = i18n.get_header_value(message, self._header) 157 | match = self._dest_mailbox_regex.search(header_value) 158 | if not match: 159 | raise ValueError( 160 | 'Could not determine destination mailbox from ' 161 | '{!r} header {!r} with regex {!r}'.format( 162 | self._header, header_value, self._dest_mailbox_regex) 163 | ) 164 | self._log.debug( 165 | '%s %r header %r matched regex %r with %r', 166 | message_id, self._header, header_value, 167 | self._dest_mailbox_regex.pattern, 168 | match.groups(), 169 | ) 170 | self._log.debug( 171 | '%s using group %s', 172 | message_id, 173 | self._dest_mailbox_regex_group, 174 | ) 175 | return '{}{}'.format( 176 | self._dest_mailbox_base, 177 | match.groups()[self._dest_mailbox_regex_group], 178 | ) 179 | 180 | def report(self, conn, src_mailbox, message_id, message): 181 | dest_mailbox = self._get_dest_mailbox(message_id, message) 182 | self._log.info( 183 | '%s (%s) to %s', 184 | message_id, i18n.get_header_value(message, 'subject'), 185 | dest_mailbox) 186 | 187 | def invoke(self, conn, src_mailbox, message_id, message): 188 | dest_mailbox = self._get_dest_mailbox(message_id, message) 189 | conn.move_message( 190 | src_mailbox, 191 | dest_mailbox, 192 | message_id, 193 | message, 194 | ) 195 | 196 | 197 | class SortMailingList(Sort): 198 | """Move the message based on the mailing list id. 199 | 200 | The action is indicated with the name ``sort-mailing-list``. 201 | 202 | This action is equivalent to the ``sort`` action with header set 203 | to ``list-id`` and ``dest-mailbox-regex`` set to 204 | ``?``. 205 | 206 | """ 207 | 208 | NAME = 'sort-mailing-list' 209 | _log = logging.getLogger(NAME) 210 | _default_header = 'list-id' 211 | _default_regex = r'?' 212 | 213 | 214 | class Trash(Move): 215 | """Move the message to the trashcan. 216 | 217 | The action is indicated with the name ``trash``. 218 | 219 | The action expects the global configuration setting 220 | ``trash-mailbox``. 221 | 222 | """ 223 | 224 | NAME = 'trash' 225 | _log = logging.getLogger(NAME) 226 | 227 | def __init__(self, action_data, cfg): 228 | super().__init__(action_data, cfg) 229 | if self._dest_mailbox is None: 230 | self._dest_mailbox = cfg.get('trash-mailbox') 231 | if self._dest_mailbox is None: 232 | raise ValueError('no "trash-mailbox" set in config') 233 | 234 | 235 | class Delete(Action): 236 | """Delete the message immediately. 237 | 238 | The action is indicated with the name ``delete``. 239 | 240 | """ 241 | 242 | NAME = 'delete' 243 | _log = logging.getLogger(NAME) 244 | 245 | def report(self, conn, mailbox_name, message_id, message): 246 | self._log.info('%s (%s)', message_id, 247 | i18n.get_header_value(message, 'subject')) 248 | 249 | def invoke(self, conn, mailbox_name, message_id, message): 250 | conn.delete_message( 251 | mailbox_name, 252 | message_id, 253 | message, 254 | ) 255 | 256 | 257 | _lookup_table = lookup.make_lookup_table(Action, 'NAME') 258 | 259 | 260 | def factory(action_data, cfg): 261 | """Create an Action instance. 262 | 263 | :param action_data: portion of configuration describing the action 264 | :type action_data: dict 265 | :param cfg: full configuration data 266 | :type cfg: dict 267 | 268 | Using the action type, instantiate an action object that can 269 | process a message. 270 | 271 | """ 272 | name = action_data.get('name') 273 | if name in _lookup_table: 274 | return _lookup_table[name](action_data, cfg) 275 | raise ValueError('unrecognized rule action {!r}'.format(action_data)) 276 | -------------------------------------------------------------------------------- /imapautofiler/tests/test_actions.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import unittest 14 | import unittest.mock as mock 15 | 16 | from imapautofiler import actions 17 | from imapautofiler.tests import base 18 | from imapautofiler.tests.base import pytest_generate_tests # noqa 19 | 20 | 21 | class TestRegisteredFactories(object): 22 | _names = [ 23 | 'move', 24 | 'sort', 25 | 'sort-mailing-list', 26 | 'trash', 27 | 'delete', 28 | ] 29 | scenarios = [ 30 | (name, {'name': name}) 31 | for name in _names 32 | ] 33 | 34 | def test_known(self, name): 35 | assert name in actions._lookup_table 36 | 37 | 38 | class TestFactory(unittest.TestCase): 39 | 40 | def test_unnamed(self): 41 | self.assertRaises(ValueError, actions.factory, {}, {}) 42 | 43 | def test_unknown(self): 44 | self.assertRaises(ValueError, actions.factory, 45 | {'name': 'unknown-action'}, {}) 46 | 47 | def test_lookup(self): 48 | with mock.patch.object(actions, '_lookup_table', {}) as lt: 49 | lt['move'] = mock.Mock() 50 | actions.factory({'name': 'move'}, {}) 51 | lt['move'].assert_called_with({'name': 'move'}, {}) 52 | 53 | 54 | class TestMove(base.TestCase): 55 | 56 | def test_create(self): 57 | m = actions.Move( 58 | {'name': 'move', 'dest-mailbox': 'msg-goes-here'}, 59 | {}, 60 | ) 61 | self.assertEqual('msg-goes-here', m._dest_mailbox) 62 | 63 | def test_invoke(self): 64 | m = actions.Move( 65 | {'name': 'move', 'dest-mailbox': 'msg-goes-here'}, 66 | {}, 67 | ) 68 | conn = mock.Mock() 69 | m.invoke(conn, 'src-mailbox', 'id-here', self.msg) 70 | conn.move_message.assert_called_once_with( 71 | 'src-mailbox', 'msg-goes-here', 'id-here', self.msg) 72 | 73 | 74 | class TestSort(base.TestCase): 75 | 76 | def test_create(self): 77 | m = actions.Sort( 78 | {'name': 'sort', 79 | 'dest-mailbox-base': 'lists-go-under-here.'}, 80 | {}, 81 | ) 82 | self.assertEqual('lists-go-under-here.', m._dest_mailbox_base) 83 | self.assertEqual(m._default_regex, m._dest_mailbox_regex.pattern) 84 | 85 | def test_create_missing_base(self): 86 | self.assertRaises( 87 | ValueError, 88 | actions.Sort, 89 | {'name': 'sort'}, 90 | {}, 91 | ) 92 | 93 | def test_create_with_regex(self): 94 | m = actions.Sort( 95 | {'name': 'sort', 96 | 'dest-mailbox-base': 'lists-go-under-here.', 97 | 'dest-mailbox-regex': ':(.*):'}, 98 | {}, 99 | ) 100 | self.assertEqual('lists-go-under-here.', m._dest_mailbox_base) 101 | self.assertEqual(':(.*):', m._dest_mailbox_regex.pattern) 102 | 103 | def test_create_bad_regex(self): 104 | self.assertRaises( 105 | ValueError, 106 | actions.Sort, 107 | {'name': 'sort', 108 | 'dest-mailbox-base': 'lists-go-under-here.', 109 | 'dest-mailbox-regex': ':.*:'}, 110 | {}, 111 | ) 112 | 113 | def test_create_with_multi_group_regex(self): 114 | m = actions.Sort( 115 | {'name': 'sort', 116 | 'dest-mailbox-base': 'lists-go-under-here.', 117 | 'dest-mailbox-regex': ':(.*):(.*):', 118 | 'dest-mailbox-regex-group': 1}, 119 | {}, 120 | ) 121 | self.assertEqual(1, m._dest_mailbox_regex_group) 122 | 123 | def test_get_dest_mailbox_default(self): 124 | m = actions.Sort( 125 | {'name': 'sort', 126 | 'dest-mailbox-base': 'lists-go-under-here.'}, 127 | {}, 128 | ) 129 | dest = m._get_dest_mailbox('id-here', self.msg) 130 | self.assertEqual( 131 | 'lists-go-under-here.recipient1', 132 | dest, 133 | ) 134 | 135 | def test_get_dest_mailbox_i18n(self): 136 | m = actions.Sort( 137 | {'name': 'sort', 138 | 'dest-mailbox-base': 'lists-go-under-here.'}, 139 | {}, 140 | ) 141 | dest = m._get_dest_mailbox('id-here', self.i18n_msg) 142 | self.assertEqual( 143 | 'lists-go-under-here.recipient3', 144 | dest, 145 | ) 146 | 147 | def test_get_dest_mailbox_regex(self): 148 | m = actions.Sort( 149 | {'name': 'sort', 150 | 'dest-mailbox-base': 'lists-go-under-here.', 151 | 'dest-mailbox-regex': r'(.*)'}, 152 | {}, 153 | ) 154 | dest = m._get_dest_mailbox('id-here', self.msg) 155 | self.assertEqual( 156 | 'lists-go-under-here.recipient1@example.com', 157 | dest, 158 | ) 159 | 160 | def test_invoke(self): 161 | m = actions.Sort( 162 | {'name': 'sort', 163 | 'dest-mailbox-base': 'lists-go-under-here.'}, 164 | {}, 165 | ) 166 | conn = mock.Mock() 167 | m.invoke(conn, 'src-mailbox', 'id-here', self.msg) 168 | conn.move_message.assert_called_once_with( 169 | 'src-mailbox', 'lists-go-under-here.recipient1', 170 | 'id-here', self.msg) 171 | 172 | 173 | class TestSortMailingList(base.TestCase): 174 | 175 | def test_create(self): 176 | m = actions.SortMailingList( 177 | {'name': 'sort-mailing-list', 178 | 'dest-mailbox-base': 'lists-go-under-here.'}, 179 | {}, 180 | ) 181 | self.assertEqual('lists-go-under-here.', m._dest_mailbox_base) 182 | self.assertEqual(m._default_regex, m._dest_mailbox_regex.pattern) 183 | 184 | def test_create_missing_base(self): 185 | self.assertRaises( 186 | ValueError, 187 | actions.SortMailingList, 188 | {'name': 'sort-mailing-list'}, 189 | {}, 190 | ) 191 | 192 | def test_create_with_regex(self): 193 | m = actions.SortMailingList( 194 | {'name': 'sort-mailing-list', 195 | 'dest-mailbox-base': 'lists-go-under-here.', 196 | 'dest-mailbox-regex': ':(.*):'}, 197 | {}, 198 | ) 199 | self.assertEqual('lists-go-under-here.', m._dest_mailbox_base) 200 | self.assertEqual(':(.*):', m._dest_mailbox_regex.pattern) 201 | 202 | def test_create_bad_regex(self): 203 | self.assertRaises( 204 | ValueError, 205 | actions.SortMailingList, 206 | {'name': 'sort-mailing-list', 207 | 'dest-mailbox-base': 'lists-go-under-here.', 208 | 'dest-mailbox-regex': ':.*:'}, 209 | {}, 210 | ) 211 | 212 | def test_create_with_multi_group_regex(self): 213 | m = actions.SortMailingList( 214 | {'name': 'sort-mailing-list', 215 | 'dest-mailbox-base': 'lists-go-under-here.', 216 | 'dest-mailbox-regex': ':(.*):(.*):', 217 | 'dest-mailbox-regex-group': 2}, 218 | {}, 219 | ) 220 | self.assertEqual(2, m._dest_mailbox_regex_group) 221 | 222 | def test_get_dest_mailbox_default(self): 223 | m = actions.SortMailingList( 224 | {'name': 'sort-mailing-list', 225 | 'dest-mailbox-base': 'lists-go-under-here.'}, 226 | {}, 227 | ) 228 | self.msg['list-id'] = '' 229 | dest = m._get_dest_mailbox('id-here', self.msg) 230 | self.assertEqual( 231 | 'lists-go-under-here.sphinx-dev', 232 | dest, 233 | ) 234 | 235 | def test_get_dest_mailbox_regex(self): 236 | m = actions.SortMailingList( 237 | {'name': 'sort-mailing-list', 238 | 'dest-mailbox-base': 'lists-go-under-here.', 239 | 'dest-mailbox-regex': r'<(.*)>'}, 240 | {}, 241 | ) 242 | self.msg['list-id'] = '' 243 | dest = m._get_dest_mailbox('id-here', self.msg) 244 | self.assertEqual( 245 | 'lists-go-under-here.sphinx-dev.googlegroups.com', 246 | dest, 247 | ) 248 | 249 | def test_invoke(self): 250 | m = actions.SortMailingList( 251 | {'name': 'sort-mailing-list', 252 | 'dest-mailbox-base': 'lists-go-under-here.', 253 | 'dest-mailbox-regex': r'<(.*)>'}, 254 | {}, 255 | ) 256 | self.msg['list-id'] = '' 257 | conn = mock.Mock() 258 | m.invoke(conn, 'src-mailbox', 'id-here', self.msg) 259 | conn.move_message.assert_called_once_with( 260 | 'src-mailbox', 'lists-go-under-here.sphinx-dev.googlegroups.com', 261 | 'id-here', self.msg) 262 | 263 | 264 | class TestTrash(base.TestCase): 265 | 266 | def test_create(self): 267 | m = actions.Trash( 268 | {'name': 'trash'}, 269 | {'trash-mailbox': 'to-the-trash'}, 270 | ) 271 | self.assertEqual('to-the-trash', m._dest_mailbox) 272 | 273 | def test_create_with_dest(self): 274 | m = actions.Trash( 275 | {'name': 'trash', 'dest-mailbox': 'local-override'}, 276 | {'trash-mailbox': 'to-the-trash'}, 277 | ) 278 | self.assertEqual('local-override', m._dest_mailbox) 279 | 280 | def test_invoke(self): 281 | m = actions.Trash( 282 | {'name': 'trash'}, 283 | {'trash-mailbox': 'to-the-trash'}, 284 | ) 285 | conn = mock.Mock() 286 | m.invoke(conn, 'src-mailbox', 'id-here', self.msg) 287 | conn.move_message.assert_called_once_with( 288 | 'src-mailbox', 'to-the-trash', 'id-here', self.msg) 289 | 290 | 291 | class TestDelete(base.TestCase): 292 | 293 | def test_invoke(self): 294 | m = actions.Delete( 295 | {'name': 'delete'}, 296 | {}, 297 | ) 298 | conn = mock.Mock() 299 | m.invoke(conn, 'src-mailbox', 'id-here', self.msg) 300 | conn.delete_message.assert_called_once_with( 301 | 'src-mailbox', 'id-here', self.msg) 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | --- License for python-keystoneclient versions prior to 2.1 --- 178 | 179 | All rights reserved. 180 | 181 | Redistribution and use in source and binary forms, with or without 182 | modification, are permitted provided that the following conditions are met: 183 | 184 | 1. Redistributions of source code must retain the above copyright notice, 185 | this list of conditions and the following disclaimer. 186 | 187 | 2. Redistributions in binary form must reproduce the above copyright 188 | notice, this list of conditions and the following disclaimer in the 189 | documentation and/or other materials provided with the distribution. 190 | 191 | 3. Neither the name of this project nor the names of its contributors may 192 | be used to endorse or promote products derived from this software without 193 | specific prior written permission. 194 | 195 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 196 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 197 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 198 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 199 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 200 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 201 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 202 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 203 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 204 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 205 | -------------------------------------------------------------------------------- /doc/source/configuring.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Configuring 3 | ============= 4 | 5 | The application is configured through a YAML file. The default file, 6 | ``~/.imapautofiler.yml``, is read if no other file is specified on the 7 | command line. 8 | 9 | Server Connection 10 | ================= 11 | 12 | Each configuration file can hold one server specification. 13 | 14 | .. code-block:: yaml 15 | 16 | server: 17 | hostname: example.com 18 | username: my-user@example.com 19 | 20 | imapautofiler also supports using the keyring_ module to store and retrieve a 21 | password from your system keyring: 22 | 23 | .. _keyring: https://pypi.python.org/pypi/keyring 24 | 25 | .. code-block:: yaml 26 | 27 | server: 28 | hostname: example.com 29 | username: my-user@example.com 30 | use_keyring: true 31 | 32 | In this scenario, you will be asked for the password on first run. The password 33 | will be stored in your operating system's secure keyring and reused when running 34 | the app. 35 | 36 | If you do not want to use the keyring, the connection section can optionally 37 | include a password. 38 | 39 | .. code-block:: yaml 40 | 41 | server: 42 | hostname: example.com 43 | username: my-user@example.com 44 | password: super-secret 45 | 46 | .. warning:: 47 | 48 | Because the password is kept in clear text, this mode of operation 49 | is only recommended when the configuration file is kept secure by 50 | other means. 51 | 52 | If the password is not provided in the configuration file and ``use_keyring`` is 53 | not true, ``imapautofiler`` will prompt for a value when it tries to connect to 54 | the server. 55 | 56 | You can also optionally provide the IMAP servers port and a custom CA file. 57 | This is helpful if your company uses custom ports and self issued certs. 58 | 59 | .. code-block:: yaml 60 | 61 | server: 62 | hostname: example.com 63 | username: my-user@example.com 64 | port: 1234 65 | ca_file: path/to/ca_file.pem 66 | 67 | 68 | Maildir Location 69 | ================ 70 | 71 | As an alternative to a server specification, the configuration file 72 | can refer to a local directory containing one or more Maildir 73 | folders. This is especially useful when combining imapautofiler with 74 | offlineimap_. 75 | 76 | .. code-block:: yaml 77 | 78 | maildir: ~/Mail 79 | 80 | .. note:: 81 | 82 | The directory specified should not itself be a Maildir. It must be 83 | a regular directory with nested Maildir folders. 84 | 85 | .. _offlineimap: http://www.offlineimap.org 86 | 87 | .. _trash-mailbox: 88 | 89 | Trash Mailbox 90 | ============= 91 | 92 | The ``trash`` action, for discarding messages without deleting them 93 | immediately, requires a configuration setting to know the name of the 94 | trash mailbox. There is no default value. 95 | 96 | .. code-block:: yaml 97 | 98 | trash-mailbox: INBOX.Trash 99 | 100 | Mailboxes 101 | ========= 102 | 103 | The mailboxes that imapautofiler should process are listed under ``mailboxes``. 104 | Each mailbox has a name and a list of rules. 105 | 106 | .. code-block:: yaml 107 | 108 | mailboxes: 109 | - name: INBOX 110 | rules: ... 111 | - name: Sent 112 | rules: ... 113 | 114 | Rules 115 | ===== 116 | 117 | The rules are organized by mailbox, and then listed in order. The 118 | first rule that matches a message triggers the associated action, and 119 | then processing for that message stops. 120 | 121 | TimeLimit Rules 122 | ---------------- 123 | 124 | An Time Limit ``time-limit`` rule is added by specifying the 'age', 125 | number of days for the email to "live" in the specified mailbox. 126 | If age = 0, the rule is ignored. 127 | 128 | .. code-block:: yaml 129 | 130 | - time-limit: 131 | age: 30 132 | 133 | Header Rules 134 | ------------ 135 | 136 | A ``header`` rule can match either a complete header value, a 137 | substring, or a regular expression against the contents of a specified 138 | message header. If a header does not exist, the content is treated as 139 | an empty string. The header text and pattern are both converted to 140 | lowercase before the comparison is performed. 141 | 142 | This example rule matches messages with the string "[pyatl]" in the 143 | subject line. 144 | 145 | .. code-block:: yaml 146 | 147 | - headers: 148 | - name: "subject" 149 | substring: "[pyatl]" 150 | action: 151 | name: "move" 152 | dest-mailbox: "INBOX.PyATL" 153 | 154 | This example rule matches messages for which the "to" header matches 155 | the regular expression ``notify-.*@disqus.net``. 156 | 157 | .. code-block:: yaml 158 | 159 | - headers: 160 | - name: to 161 | regex: "notify-.*@disqus.net" 162 | action: 163 | name: trash 164 | 165 | This example rule matches messages for which the "Message-Id" header 166 | is exactly ``<4FF56508-357B-4E73-82DE-458D3EEB2753@example.com>``. 167 | 168 | .. code-block:: yaml 169 | 170 | - headers: 171 | - name: to 172 | value: "<4FF56508-357B-4E73-82DE-458D3EEB2753@example.com>" 173 | action: 174 | name: trash 175 | 176 | Combination Rules 177 | ----------------- 178 | 179 | It is frequently useful to be able to apply the same action to 180 | messages with different characteristics. For example, if a mailing 181 | list ID appears in the subject line or in the ``list-id`` header. The 182 | ``or`` rule allows nested rules. If any one matches, the combined rule 183 | matches and the associated action is triggered. 184 | 185 | For example, this rule matches any message where the PyATL meetup 186 | mailing list address is in the ``to`` or ``cc`` headers. 187 | 188 | .. code-block:: yaml 189 | 190 | - or: 191 | rules: 192 | - headers: 193 | - name: "to" 194 | substring: "pyatl-list@meetup.com" 195 | - headers: 196 | - name: "cc" 197 | substring: "pyatl-list@meetup.com" 198 | action: 199 | name: "move" 200 | dest-mailbox: "INBOX.PyATL" 201 | 202 | For more complicated formulations, the ``and`` rule allows combining 203 | other rules so that they all must match the message before the action 204 | is taken. 205 | 206 | For example, this rule matches any message sent to the PyATL meetup 207 | mailing list address with a subject including the text ``"meeting 208 | update"``. 209 | 210 | .. code-block:: yaml 211 | 212 | - and: 213 | rules: 214 | - headers: 215 | - name: "to" 216 | substring: "pyatl-list@meetup.com" 217 | - headers: 218 | - name: "subject" 219 | substring: "meeting update" 220 | action: 221 | name: "move" 222 | dest-mailbox: "INBOX.PyATL" 223 | 224 | Recipient Rules 225 | --------------- 226 | 227 | The example presented for ``or`` rules is a common enough case that it 228 | is supported directly using the ``recipient`` rule. If any header 229 | listing a recipient of the message matches the substring or regular 230 | expression, the action is triggered. 231 | 232 | This example is equivalent to the example for ``or``. 233 | 234 | .. code-block:: yaml 235 | 236 | - recipient: 237 | substring: "pyatl-list@meetup.com" 238 | action: 239 | name: "move" 240 | dest-mailbox: "INBOX.PyATL" 241 | 242 | Actions 243 | ======= 244 | 245 | Each rule is associated with an *action* to be triggered when the rule 246 | matches a message. 247 | 248 | Move Action 249 | ----------- 250 | 251 | The ``move`` action copies the message to a new mailbox and then 252 | deletes the version in the source mailbox. This action can be used to 253 | automatically file messages. 254 | 255 | The example below moves any message sent to the PyATL meetup group 256 | mailing list into the mailbox ``INBOX.PyATL``. 257 | 258 | .. code-block:: yaml 259 | 260 | - recipient: 261 | substring: "pyatl-list@meetup.com" 262 | action: 263 | name: "move" 264 | dest-mailbox: "INBOX.PyATL" 265 | 266 | Different IMAP servers may use different naming conventions for 267 | mailbox hierarchies. Use the ``--list-mailboxes`` option to the 268 | command line program to print a list of all of the mailboxes known to 269 | the account. 270 | 271 | Sort Action 272 | ----------- 273 | 274 | The ``sort`` action uses data in a message header to determine the 275 | destination mailbox for the message. This action can be used to 276 | automatically file messages from mailing lists or other common sources 277 | if the corresponding mailbox hierarchy is established. A ``sort`` 278 | action is equivalent to ``move`` except that the destination is 279 | determined dynamically. 280 | 281 | The action settings may contain a ``header`` entry to specify the name 282 | of the mail header to examine to find the destination. The default is 283 | to use the ``to`` header. 284 | 285 | The action data may contain a ``dest-mailbox-regex`` entry for parsing 286 | the header value to obtain the destination mailbox name. If the regex 287 | has one match group, that substring will be used. If the regex has 288 | more than one match group, the ``dest-mailbox-regex-group`` option 289 | must specify which group to use (0-based numerical index). The default 290 | pattern is ``([\w-+]+)@`` to match the first part of an email address. 291 | 292 | The action data must contain a ``dest-mailbox-base`` entry with the 293 | base name of the destination mailbox. The actual mailbox name will be 294 | constructed by appending the value extracted via 295 | ``dest-mailbox-regex`` to the ``dest-mailbox-base`` value. The 296 | ``dest-mailbox-base`` value should contain the mailbox separator 297 | character (usually ``.``) if the desired mailbox is a sub-folder of 298 | the name given. 299 | 300 | The example below sorts messages associated with two mailing lists 301 | into separate mailboxes under a parent mailbox ``INBOX.ML``. It uses 302 | the default regular expression to extract the prefix of the ``to`` 303 | header for each message. Messages to the 304 | ``python-committers@python.org`` mailing list are sorted into 305 | ``INBOX.ML.python-committers`` and messages to the 306 | ``sphinx-dev@googlegroups.com`` list are sorted into 307 | ``INBOX.ML.sphinx-dev``. 308 | 309 | .. code-block:: yaml 310 | 311 | - or: 312 | rules: 313 | - recipient: 314 | substring: python-committers@python.org 315 | - recipient: 316 | substring: sphinx-dev@googlegroups.com 317 | action: 318 | name: sort 319 | dest-mailbox-base: "INBOX.ML." 320 | 321 | Sort Mailing List Action 322 | ------------------------ 323 | 324 | The ``sort-mailing-list`` action works like ``sort`` configured to 325 | read the ``list-id`` header and extract the portion of the ID between 326 | ``<`` and ``>``. if they are present. If there are no angle brackets 327 | in the ID, the entire value is used. As with ``sort`` the 328 | ``dest-mailbox-regex`` can be specified in the rule to change this 329 | behavior. 330 | 331 | The example below sorts messages to any mailing list into separate 332 | folders under ``INBOX.ML``. 333 | 334 | .. code-block:: yaml 335 | 336 | - is-mailing-list: {} 337 | action: 338 | name: sort-mailing-list 339 | dest-mailbox-base: "INBOX.ML." 340 | 341 | Trash Action 342 | ------------ 343 | 344 | Moving messages to the "trash can" is a less immediate way of deleting 345 | them. Messages in the trash can can typically be recovered until they 346 | expire, or until the trash is emptied explicitly. 347 | 348 | Using this action requires setting the global ``trash-mailbox`` option 349 | (see :ref:`trash-mailbox`). If the action is triggered and the option 350 | is not set, the action reports an error and processing stops. 351 | 352 | This example moves messages for which the "to" header matches the 353 | regular expression ``notify-.*@disqus.net`` to the trash mailbox. 354 | 355 | .. code-block:: yaml 356 | 357 | - headers: 358 | - name: to 359 | regex: "notify-.*@disqus.net" 360 | action: 361 | name: trash 362 | 363 | Delete Action 364 | ------------- 365 | 366 | The ``delete`` action is more immediately destructive. Messages are 367 | permanently removed from the mailbox as soon as the mailbox is closed. 368 | 369 | This example deletes messages for which the "to" header matches the 370 | regular expression ``notify-.*@disqus.net``. 371 | 372 | .. code-block:: yaml 373 | 374 | - headers: 375 | - name: to 376 | regex: "notify-.*@disqus.net" 377 | action: 378 | name: delete 379 | 380 | Complete example configuration file 381 | =================================== 382 | 383 | Here's an example of a configuration file with all the possible parts. 384 | 385 | .. code-block:: yaml 386 | 387 | server: 388 | hostname: imap.gmail.com 389 | username: user@example.com 390 | password: xxxxxxxxxxxxxx 391 | 392 | trash-mailbox: "[Gmail]/Trash" 393 | 394 | mailboxes: 395 | - name: INBOX 396 | rules: 397 | - headers: 398 | - name: "from" 399 | substring: user1@example.com 400 | action: 401 | name: "move" 402 | dest-mailbox: "User1 correspondence" 403 | - headers: 404 | - name: recipient 405 | substring: dev-team 406 | - name: subject 407 | substring: "[Django] ERROR" 408 | action: 409 | name: "move" 410 | dest-mailbox: "Django Errors" 411 | -------------------------------------------------------------------------------- /imapautofiler/tests/test_rules.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 2 | # not use this file except in compliance with the License. You may obtain 3 | # a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 | # License for the specific language governing permissions and limitations 11 | # under the License. 12 | 13 | import unittest 14 | import unittest.mock as mock 15 | 16 | from imapautofiler import rules 17 | from imapautofiler.tests import base 18 | from imapautofiler.tests.base import pytest_generate_tests # noqa 19 | 20 | 21 | class TestRegisteredFactories(object): 22 | _names = [ 23 | 'or', 24 | 'and', 25 | 'recipient', 26 | 'time-limit', 27 | 'headers', 28 | 'header-exists', 29 | 'is-mailing-list', 30 | ] 31 | scenarios = [ 32 | (name, {'name': name}) 33 | for name in _names 34 | ] 35 | 36 | def test_known(self, name): 37 | assert name in rules._lookup_table 38 | 39 | 40 | class TestFactory(unittest.TestCase): 41 | 42 | def test_unnamed(self): 43 | self.assertRaises(ValueError, rules.factory, {}, {}) 44 | 45 | def test_unknown(self): 46 | self.assertRaises(ValueError, rules.factory, 47 | {'unknown-rule': {}}, {}) 48 | 49 | def test_lookup(self): 50 | with mock.patch.object(rules, '_lookup_table', {}) as lt: 51 | lt['or'] = mock.Mock() 52 | rules.factory({'or': {}}, {}) 53 | lt['or'].assert_called_with({'or': {}}, {}) 54 | 55 | 56 | class TestOr(base.TestCase): 57 | 58 | def test_create_recursive(self): 59 | rule_def = { 60 | 'or': { 61 | 'rules': [ 62 | {'headers': [ 63 | {'name': 'to', 64 | 'substring': 'recipient1@example.com'}]}, 65 | {'headers': [ 66 | {'name': 'cc', 67 | 'substring': 'recipient1@example.com'}]} 68 | ], 69 | }, 70 | } 71 | r = rules.Or(rule_def, {}) 72 | self.assertIsInstance(r._sub_rules[0], rules.Headers) 73 | self.assertIsInstance(r._sub_rules[1], rules.Headers) 74 | self.assertEqual(len(r._sub_rules), 2) 75 | 76 | def test_check_pass_first(self): 77 | rule_def = {'or': {'rules': []}} 78 | r = rules.Or(rule_def, {}) 79 | r1 = mock.Mock() 80 | r1.check.return_value = True 81 | r._sub_rules.append(r1) 82 | r2 = mock.Mock() 83 | r2.check.return_value = False 84 | r._sub_rules.append(r2) 85 | self.assertTrue(r.check(self.msg)) 86 | 87 | def test_check_short_circuit(self): 88 | rule_def = {'or': {'rules': []}} 89 | r = rules.Or(rule_def, {}) 90 | r1 = mock.Mock() 91 | r1.check.return_value = True 92 | r._sub_rules.append(r1) 93 | r2 = mock.Mock() 94 | r2.check.side_effect = AssertionError('r2 should not be called') 95 | r._sub_rules.append(r2) 96 | self.assertTrue(r.check(self.msg)) 97 | 98 | def test_check_pass_second(self): 99 | rule_def = {'or': {'rules': []}} 100 | r = rules.Or(rule_def, {}) 101 | r1 = mock.Mock() 102 | r1.check.return_value = False 103 | r._sub_rules.append(r1) 104 | r2 = mock.Mock() 105 | r2.check.return_value = True 106 | r._sub_rules.append(r2) 107 | self.assertTrue(r.check(self.msg)) 108 | 109 | def test_check_no_match(self): 110 | rule_def = {'or': {'rules': []}} 111 | r = rules.Or(rule_def, {}) 112 | r1 = mock.Mock() 113 | r1.check.return_value = False 114 | r._sub_rules.append(r1) 115 | r2 = mock.Mock() 116 | r2.check.return_value = False 117 | r._sub_rules.append(r2) 118 | self.assertFalse(r.check(self.msg)) 119 | 120 | def test_check_no_subrules(self): 121 | rule_def = {'or': {'rules': []}} 122 | r = rules.Or(rule_def, {}) 123 | self.assertFalse(r.check(self.msg)) 124 | 125 | 126 | class TestAnd(base.TestCase): 127 | 128 | def test_create_recursive(self): 129 | rule_def = { 130 | 'and': { 131 | 'rules': [ 132 | {'headers': [ 133 | {'name': 'to', 134 | 'substring': 'recipient1@example.com'}]}, 135 | {'headers': [ 136 | {'name': 'cc', 137 | 'substring': 'recipient2@example.com'}]} 138 | ], 139 | }, 140 | } 141 | r = rules.And(rule_def, {}) 142 | self.assertIsInstance(r._sub_rules[0], rules.Headers) 143 | self.assertIsInstance(r._sub_rules[1], rules.Headers) 144 | self.assertEqual(len(r._sub_rules), 2) 145 | 146 | def test_check_fail_one_1(self): 147 | rule_def = {'and': {'rules': []}} 148 | r = rules.And(rule_def, {}) 149 | r1 = mock.Mock() 150 | r1.check.return_value = True 151 | r._sub_rules.append(r1) 152 | r2 = mock.Mock() 153 | r2.check.return_value = False 154 | r._sub_rules.append(r2) 155 | self.assertFalse(r.check(self.msg)) 156 | 157 | def test_check_fail_one_2(self): 158 | rule_def = {'and': {'rules': []}} 159 | r = rules.And(rule_def, {}) 160 | r1 = mock.Mock() 161 | r1.check.return_value = False 162 | r._sub_rules.append(r1) 163 | r2 = mock.Mock() 164 | r2.check.return_value = True 165 | r._sub_rules.append(r2) 166 | self.assertFalse(r.check(self.msg)) 167 | 168 | def test_check_short_circuit(self): 169 | rule_def = {'and': {'rules': []}} 170 | r = rules.And(rule_def, {}) 171 | r1 = mock.Mock() 172 | r1.check.return_value = False 173 | r._sub_rules.append(r1) 174 | r2 = mock.Mock() 175 | r2.check.side_effect = AssertionError('r2 should not be called') 176 | r._sub_rules.append(r2) 177 | self.assertFalse(r.check(self.msg)) 178 | 179 | def test_check_pass_second(self): 180 | rule_def = {'and': {'rules': []}} 181 | r = rules.And(rule_def, {}) 182 | r1 = mock.Mock() 183 | r1.check.return_value = True 184 | r._sub_rules.append(r1) 185 | r2 = mock.Mock() 186 | r2.check.return_value = True 187 | r._sub_rules.append(r2) 188 | self.assertTrue(r.check(self.msg)) 189 | 190 | def test_check_no_match(self): 191 | rule_def = {'and': {'rules': []}} 192 | r = rules.And(rule_def, {}) 193 | r1 = mock.Mock() 194 | r1.check.return_value = False 195 | r._sub_rules.append(r1) 196 | r2 = mock.Mock() 197 | r2.check.return_value = False 198 | r._sub_rules.append(r2) 199 | self.assertFalse(r.check(self.msg)) 200 | 201 | def test_check_no_subrules(self): 202 | rule_def = {'and': {'rules': []}} 203 | r = rules.And(rule_def, {}) 204 | self.assertFalse(r.check(self.msg)) 205 | 206 | 207 | class TestHeaderExactValue(base.TestCase): 208 | def test_match(self): 209 | rule_def = { 210 | 'name': 'to', 211 | 'value': 'recipient1@example.com', 212 | } 213 | r = rules.HeaderExactValue(rule_def, {}) 214 | self.assertTrue(r.check(self.msg)) 215 | 216 | def test_no_match(self): 217 | rule_def = { 218 | 'name': 'to', 219 | 'value': 'not_the_recipient1@example.com', 220 | } 221 | r = rules.HeaderExactValue(rule_def, {}) 222 | self.assertFalse(r.check(self.msg)) 223 | 224 | def test_no_such_header(self): 225 | rule_def = { 226 | 'name': 'this_header_not_present', 227 | 'value': 'recipient1@example.com', 228 | } 229 | r = rules.HeaderExactValue(rule_def, {}) 230 | self.assertFalse(r.check(self.msg)) 231 | 232 | def test_i18n_match(self): 233 | rule_def = { 234 | 'name': 'subject', 235 | 'value': 'Re: ответ на предыдущее сообщение', 236 | } 237 | r = rules.HeaderExactValue(rule_def, {}) 238 | self.assertTrue(r.check(self.i18n_msg)) 239 | 240 | def test_i18n_no_match(self): 241 | rule_def = { 242 | 'name': 'subject', 243 | 'value': 'Re: что-то другое', 244 | } 245 | r = rules.HeaderExactValue(rule_def, {}) 246 | self.assertFalse(r.check(self.i18n_msg)) 247 | 248 | def test_i18n_no_such_header(self): 249 | rule_def = { 250 | 'name': 'this_header_not_present', 251 | 'value': 'такого заголовка нет', 252 | } 253 | r = rules.HeaderExactValue(rule_def, {}) 254 | self.assertFalse(r.check(self.i18n_msg)) 255 | 256 | 257 | class TestHeaderSubString(base.TestCase): 258 | 259 | def test_match(self): 260 | rule_def = { 261 | 'name': 'to', 262 | 'substring': 'recipient1@example.com', 263 | } 264 | r = rules.HeaderSubString(rule_def, {}) 265 | self.assertTrue(r.check(self.msg)) 266 | 267 | def test_no_match(self): 268 | rule_def = { 269 | 'name': 'to', 270 | 'substring': 'not_the_recipient1@example.com', 271 | } 272 | r = rules.HeaderSubString(rule_def, {}) 273 | self.assertFalse(r.check(self.msg)) 274 | 275 | def test_no_such_header(self): 276 | rule_def = { 277 | 'name': 'this_header_not_present', 278 | 'substring': 'recipient1@example.com', 279 | } 280 | r = rules.HeaderSubString(rule_def, {}) 281 | self.assertFalse(r.check(self.msg)) 282 | 283 | def test_i18n_match(self): 284 | rule_def = { 285 | 'name': 'subject', 286 | 'substring': 'предыдущее', 287 | } 288 | r = rules.HeaderSubString(rule_def, {}) 289 | self.assertTrue(r.check(self.i18n_msg)) 290 | 291 | def test_i18n_no_match(self): 292 | rule_def = { 293 | 'name': 'subject', 294 | 'substring': 'что-то другое', 295 | } 296 | r = rules.HeaderSubString(rule_def, {}) 297 | self.assertFalse(r.check(self.i18n_msg)) 298 | 299 | def test_i18n_no_such_header(self): 300 | rule_def = { 301 | 'name': 'this_header_not_present', 302 | 'substring': 'такого заголовка нет', 303 | } 304 | r = rules.HeaderSubString(rule_def, {}) 305 | self.assertFalse(r.check(self.i18n_msg)) 306 | 307 | 308 | class TestHeaderRegex(base.TestCase): 309 | 310 | def test_match(self): 311 | rule_def = { 312 | 'name': 'to', 313 | 'regex': 'recipient.*@example.com', 314 | } 315 | r = rules.HeaderRegex(rule_def, {}) 316 | self.assertTrue(r.check(self.msg)) 317 | 318 | def test_no_match(self): 319 | rule_def = { 320 | 'name': 'to', 321 | 'regex': 'not_the_recipient.*@example.com', 322 | } 323 | r = rules.HeaderRegex(rule_def, {}) 324 | self.assertFalse(r.check(self.msg)) 325 | 326 | def test_no_such_header(self): 327 | rule_def = { 328 | 'name': 'this_header_not_present', 329 | 'regex': 'not_the_recipient.*@example.com', 330 | } 331 | r = rules.HeaderRegex(rule_def, {}) 332 | self.assertFalse(r.check(self.msg)) 333 | 334 | def test_i18n_match(self): 335 | rule_def = { 336 | 'name': 'subject', 337 | 'regex': 'предыдущее', 338 | } 339 | r = rules.HeaderRegex(rule_def, {}) 340 | self.assertTrue(r.check(self.i18n_msg)) 341 | 342 | def test_i18n_no_match(self): 343 | rule_def = { 344 | 'name': 'subject', 345 | 'regex': 'что-то другое', 346 | } 347 | r = rules.HeaderRegex(rule_def, {}) 348 | self.assertFalse(r.check(self.i18n_msg)) 349 | 350 | def test_i18n_no_such_header(self): 351 | rule_def = { 352 | 'name': 'this_header_not_present', 353 | 'regex': 'такого заголовка нет', 354 | } 355 | r = rules.HeaderRegex(rule_def, {}) 356 | self.assertFalse(r.check(self.i18n_msg)) 357 | 358 | 359 | class TestHeaderExists(base.TestCase): 360 | 361 | def test_exists(self): 362 | rule_def = { 363 | 'name': 'references', 364 | } 365 | r = rules.HeaderExists(rule_def, {}) 366 | self.assertTrue(r.check(self.msg)) 367 | 368 | def test_exists_no_case(self): 369 | rule_def = { 370 | 'name': 'REFERENCES', 371 | } 372 | r = rules.HeaderExists(rule_def, {}) 373 | self.assertTrue(r.check(self.msg)) 374 | 375 | def test_no_exists(self): 376 | rule_def = { 377 | 'name': 'no-such-header', 378 | } 379 | r = rules.HeaderExists(rule_def, {}) 380 | self.assertFalse(r.check(self.msg)) 381 | 382 | 383 | class TestIsMailingList(base.TestCase): 384 | 385 | def test_yes(self): 386 | rule_def = {} 387 | r = rules.IsMailingList(rule_def, {}) 388 | self.msg['list-id'] = '' 389 | self.assertTrue(r.check(self.msg)) 390 | 391 | def test_no(self): 392 | rule_def = {} 393 | r = rules.IsMailingList(rule_def, {}) 394 | self.assertFalse(r.check(self.msg)) 395 | 396 | 397 | class TestHeaders(base.TestCase): 398 | 399 | def test_create_recursive(self): 400 | rule_def = { 401 | 'headers': [ 402 | {'name': 'to', 403 | 'substring': 'recipient1@example.com'}, 404 | {'name': 'cc', 405 | 'substring': 'recipient1@example.com'}, 406 | ], 407 | } 408 | r = rules.Headers(rule_def, {}) 409 | self.assertIsInstance(r._matchers[0], rules.HeaderSubString) 410 | self.assertIsInstance(r._matchers[1], rules.HeaderSubString) 411 | self.assertEqual(len(r._matchers), 2) 412 | 413 | def test_check_no_short_circuit(self): 414 | rule_def = {'or': {'rules': []}} 415 | r = rules.Headers(rule_def, {}) 416 | r1 = mock.Mock() 417 | r1.check.return_value = True 418 | r._matchers.append(r1) 419 | r2 = mock.Mock() 420 | r2.check.return_value = True 421 | r._matchers.append(r2) 422 | self.assertTrue(r.check(self.msg)) 423 | r1.check.assert_called_once_with(self.msg) 424 | r2.check.assert_called_once_with(self.msg) 425 | 426 | def test_fail_one(self): 427 | rule_def = {'or': {'rules': []}} 428 | r = rules.Headers(rule_def, {}) 429 | r1 = mock.Mock() 430 | r1.check.return_value = False 431 | r._matchers.append(r1) 432 | r2 = mock.Mock() 433 | r2.check.return_value = True 434 | r._matchers.append(r2) 435 | self.assertFalse(r.check(self.msg)) 436 | 437 | def test_check_no_match(self): 438 | rule_def = {'or': {'rules': []}} 439 | r = rules.Headers(rule_def, {}) 440 | r1 = mock.Mock() 441 | r1.check.return_value = False 442 | r._matchers.append(r1) 443 | r2 = mock.Mock() 444 | r2.check.return_value = False 445 | r._matchers.append(r2) 446 | self.assertFalse(r.check(self.msg)) 447 | 448 | def test_check_no_matchers(self): 449 | rule_def = {'or': {'rules': []}} 450 | r = rules.Headers(rule_def, {}) 451 | self.assertFalse(r.check(self.msg)) 452 | 453 | 454 | class TestRecipient(base.TestCase): 455 | 456 | def test_create_recursive(self): 457 | rule_def = { 458 | 'recipient': {'substring': 'recipient1@example.com'}, 459 | } 460 | r = rules.Recipient(rule_def, {}) 461 | self.assertEquals( 462 | { 463 | 'recipient': {'substring': 'recipient1@example.com'}, 464 | 'or': { 465 | 'rules': [ 466 | { 467 | 'headers': [{ 468 | 'name': 'to', 469 | 'substring': 'recipient1@example.com', 470 | }], 471 | }, 472 | { 473 | 'headers': [{ 474 | 'name': 'cc', 475 | 'substring': 'recipient1@example.com', 476 | }], 477 | }, 478 | ], 479 | }, 480 | }, 481 | r._data, 482 | ) 483 | 484 | 485 | class TestTimeLimit(base.TestCase): 486 | """Test TimeLimit class handling of passed and permitted messages.""" 487 | def get_def(self): 488 | rule_def = { 489 | 'time-limit': { 490 | 'age': 30, 491 | } 492 | } 493 | return rule_def 494 | 495 | def test_time_limit_expired(self): 496 | r = rules.TimeLimit(self.get_def(), {}) 497 | self.assertTrue(r.check(self.msg)) 498 | 499 | def test_time_limit_current(self): 500 | r = rules.TimeLimit(self.get_def(), {}) 501 | self.assertEqual(r.check(self.recent_msg), 0) 502 | --------------------------------------------------------------------------------