├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── NEWS.md ├── README.rst ├── afew ├── Database.py ├── FilterRegistry.py ├── MailMover.py ├── NotmuchSettings.py ├── Settings.py ├── __init__.py ├── __main__.py ├── commands.py ├── configparser.py ├── defaults │ └── afew.config ├── files.py ├── filters │ ├── ArchiveSentMailsFilter.py │ ├── BaseFilter.py │ ├── DKIMValidityFilter.py │ ├── DMARCReportInspectionFilter.py │ ├── FolderNameFilter.py │ ├── HeaderMatchingFilter.py │ ├── InboxFilter.py │ ├── KillThreadsFilter.py │ ├── ListMailsFilter.py │ ├── MeFilter.py │ ├── PropagateTagsByRegexInThreadFilter.py │ ├── PropagateTagsInThreadFilter.py │ ├── SentMailsFilter.py │ ├── SpamFilter.py │ └── __init__.py ├── main.py ├── tests │ ├── __init__.py │ ├── test_dkimvalidityfilter.py │ ├── test_headermatchingfilter.py │ ├── test_mailmover.py │ └── test_settings.py └── utils.py ├── docs ├── _static │ └── .keep ├── commandline.rst ├── conf.py ├── configuration.rst ├── extending.rst ├── filters.rst ├── implementation.rst ├── index.rst ├── installation.rst ├── move_mode.rst └── quickstart.rst └── setup.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Trigger the workflow on push or pull request 4 | on: [push, pull_request] 5 | 6 | jobs: 7 | build-ubuntu: 8 | strategy: 9 | matrix: 10 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 11 | name: Build (Python ${{ matrix.python-version }}) 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '${{ matrix.python-version }}' 21 | - name: Display Python version 22 | run: python -c "import sys; print(sys.version)" 23 | - name: Install dependencies 24 | shell: bash 25 | run: | 26 | sudo apt-get update 27 | sudo apt-get install -y notmuch libnotmuch-dev python3-venv flake8 28 | python3 -m venv env 29 | source ./env/bin/activate 30 | pip install setuptools setuptools_scm pytest dkimpy 31 | pip install notmuch2 32 | - name: flake8 lint 33 | run: | 34 | source ./env/bin/activate 35 | flake8 --ignore=E501,W504 afew/ 36 | - name: Tests 37 | run: | 38 | source ./env/bin/activate 39 | pip install freezegun 40 | pytest 41 | - name: build 42 | run: | 43 | source ./env/bin/activate 44 | python setup.py build 45 | - name: install 46 | run: | 47 | source ./env/bin/activate 48 | python setup.py install 49 | - name: Docs 50 | run: | 51 | source ./env/bin/activate 52 | pip install sphinx 53 | sphinx-build -b html docs $(mktemp -d) 54 | sphinx-build -b man docs $(mktemp -d) 55 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | release: 10 | needs: [build-ubuntu] 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.x' 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools setuptools_scm wheel twine 24 | - name: Build and publish (testpypi) 25 | env: 26 | TWINE_USERNAME: __token__ 27 | TWINE_PASSWORD: ${{ secrets.testpypi_token }} 28 | TWINE_REPOSITORY: testpypi 29 | run: | 30 | python setup.py sdist 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | /dist 3 | /build 4 | bin/ 5 | include/ 6 | lib/ 7 | /afew.egg-info 8 | afew/version.py 9 | /.eggs 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | # python: 21 | # install: 22 | # - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) afewmail project 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | afew 3.1.0 (unreleased) 2 | ======================= 3 | 4 | Python 3.6 support dropped 5 | 6 | afew stopped supporting the older python version 3.6. 7 | 8 | Handle DMARC report with empty spf or dkim XML nodes 9 | 10 | DMARC Filter: allow to define subject regexp 11 | 12 | Some DMARC report mail have a prefix before "Dmarc Report" in the subject 13 | and where not checked by the plugin. 14 | Afew now allows the user to define the suject regexp in the config file. 15 | 16 | Get notmuch database path using Database wrapper 17 | 18 | This allows FolderNameFilter to work with a relative path in database.path of notmuch config file. 19 | 20 | HeaderMatchingFilter: do not convert user supplied tags 21 | 22 | This prevents afew to lowercase the tags defined by the user, allowing to have non lowercase tags. 23 | 24 | afew 3.0.0 (2020-03-10) 25 | ======================= 26 | 27 | MailMover: many fixes 28 | 29 | Previously, MailMover didn't properly preserve flags when renaming files, and 30 | moved all mail to `cur`. This was fixed. Also, MailMover gained a test suite. 31 | 32 | New filters: PropagateTags[ByRegex]InThreadFilter 33 | 34 | These filters allow propagating tags set to a message to the whole thread. 35 | 36 | New command line argument: --notmuch-args= in move mode 37 | 38 | In move mode, afew calls `notmuch new` after moving mails around. This 39 | prevents `afew -m` from being used in a pre-new hook in `notmuch`. 40 | 41 | Now it's possible to specify notmuch args, so something like `afew -m 42 | --notmuch-args=--no-hooks` can live happily in a pre-new hook. 43 | 44 | Python 3.4 and 3.5 support dropped 45 | 46 | afew stopped supporting the older python versions 3.4 and 3.5, and removed 47 | some more Python 2 compatibility code. (`from __future__ import …`, utf-8 48 | headers, relative imports, …) 49 | 50 | afew 2.0.0 (2019-06-16) 51 | ======================= 52 | 53 | Python 2 support removed 54 | 55 | afew doesn't support Python 2 anymore, and all Python 2 specific compat hacks 56 | were removed. 57 | 58 | Better support for whitespaces and quotes in folder names 59 | 60 | Previously, afew failed with folders containing quotes or namespaces. These 61 | are now properly escaped internally. 62 | 63 | Support `MAILDIR` as fallback for database location 64 | 65 | In addition to reading notmuch databse location from notmuch config, afew now 66 | supports reading from the `MAILDIR` environment variable, like notmuch CLI 67 | does, too. 68 | 69 | Support relative path for database location 70 | 71 | As of notmuch 0.28, a relative path may be provided for the database 72 | location. notmuch prepends `$HOME/` to the relative path. For feature 73 | parity, afew now supports the same methodology of prepending `$HOME/` if a 74 | relative path is provided. 75 | 76 | Support for removing unread and read tags in filters 77 | 78 | In a filter rule, it was possible to add "unread" and "read" tags but 79 | not to remove them. 80 | 81 | afew 1.3.0 (2018-02-06) 82 | ======================= 83 | 84 | MeFilter added 85 | 86 | Add filter tagging mail sent directly to any of addresses defined in 87 | Notmuch config file: `primary_email` or `other_email`. 88 | Default tag is `to-me` and can be customized with `me_tag` option. 89 | 90 | License comments replaced with SPDX-License-Identifier 91 | 92 | Where possible, license boilerplate comments were changed to just the 93 | SPDX-License-Identifier, while adding the license to the repo and referencing 94 | it in `setup.py`, too. 95 | 96 | DMARCReportInspectionFilter added 97 | 98 | DMARC reports usually come in ZIP files. To check the report you have to 99 | unpack and search thru XML document which is very tedious. The filter tags the 100 | message as follows: 101 | 102 | if there's any SPF failure in any attachment, tag the message with 103 | "dmarc-spf-fail" tag, otherwise tag with "dmarc-spf-ok" 104 | 105 | if there's any DKIM failure in any attachment, tag the message with 106 | "dmarc-dkim-fail" tag, otherwise tag with "dmarc-dkim-ok" 107 | 108 | DKIMValidityFilter added 109 | This filter verifies DKIM signatures of E-Mails with DKIM header, and adds 110 | `dkin-ok` or `dkin-fail` tags. 111 | 112 | 113 | afew 1.2.0 (2017-08-07) 114 | ======================= 115 | 116 | FolderNameFilter supporting mails in multiple directories 117 | 118 | FolderNameFilter now looks at all folders that a message is in when adding 119 | tags to it. 120 | 121 | afew 1.1.0 (2017-06-12) 122 | ======================= 123 | 124 | Classification system removed 125 | 126 | As of commit 86d881d948c6ff00a6475dee97551ea092e526a1, the classification 127 | system (--learn) was removed, as it was really broken. If someone wants to 128 | implement it properly in the future it would be much simpler to start from 129 | scratch. 130 | 131 | afew 1.0.0 (2017-02-13) 132 | ===================== 133 | 134 | Filter behaviour change 135 | 136 | As of commit d98a0cd0d1f37ee64d03be75e75556cff9f32c29, the ListMailsFilter 137 | does not add a tag named `list-id `anymore, but a new one called 138 | `lists/`. 139 | 140 | afew 0.1pre (2012-02-10) 141 | ======================== 142 | 143 | Configuration format change 144 | 145 | Previously the values for configuration entries with the key `tags` 146 | were interpreted as a whitespace delimited list of strings. As of 147 | commit e4ec3ced16cc90c3e9c738630bf0151699c4c087 those entries are 148 | split at semicolons (';'). 149 | 150 | This changes the semantic of the configuration file and affects 151 | everyone who uses filter rules that set or remove more than one tag 152 | at once. Please inspect your configuration files and adjust them if 153 | necessary. 154 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | afew 3 | ==== 4 | 5 | |GithubTag| |CI Status| 6 | 7 | About 8 | ----- 9 | 10 | afew is an initial tagging script for notmuch mail: 11 | 12 | * http://notmuchmail.org/ 13 | * http://notmuchmail.org/initial_tagging/ 14 | 15 | Its basic task is to provide automatic tagging each time new mail is registered 16 | with notmuch. In a classic setup, you might call it after 'notmuch new' in an 17 | offlineimap post sync hook. 18 | 19 | It can do basic thing such as adding tags based on email headers or maildir 20 | folders, handling killed threads and spam. 21 | 22 | In move mode, afew will move mails between maildir folders according to 23 | configurable rules that can contain arbitrary notmuch queries to match against 24 | any searchable attributes. 25 | 26 | fyi: afew plays nicely with alot, a TUI for notmuch mail ;) 27 | 28 | * https://github.com/pazz/alot 29 | 30 | 31 | 32 | IRC 33 | --- 34 | 35 | Feel free to ask your questions and discuss usage in the `#afewmail IRC Channel`_ on Libera.Chat. 36 | 37 | .. _#afewmail IRC Channel: http://web.libera.chat/?channels=#afewmail 38 | 39 | 40 | Features 41 | -------- 42 | 43 | * spam handling (flush all tags, add spam) 44 | * killed thread handling 45 | * automatic propagation of tags to whole thread 46 | * tags posts to lists with ``lists``, ``$list-id`` 47 | * autoarchives mails sent from you 48 | * catchall -> remove ``new``, add ``inbox`` 49 | * can operate on new messages [default], ``--all`` messages or on custom 50 | query results 51 | * can move mails based on arbitrary notmuch queries, so your sorting 52 | may show on your traditional mail client (well, almost ;)) 53 | * has a ``--dry-run`` mode for safe testing 54 | 55 | 56 | 57 | Installation and Usage 58 | ---------------------- 59 | 60 | Full documentation is available in the `docs/`_ directory and in 61 | rendered form at afew.readthedocs.io_. 62 | 63 | .. _afew.readthedocs.io: https://afew.readthedocs.io/en/latest/ 64 | .. _docs/: docs/ 65 | 66 | Have fun :) 67 | 68 | 69 | .. |GithubTag| image:: https://img.shields.io/github/tag/afewmail/afew.svg 70 | :target: https://github.com/afewmail/afew/releases 71 | .. |CI Status| image:: https://github.com/afewmail/afew/workflows/CI/badge.svg 72 | :target: https://github.com/afewmail/afew/actions 73 | -------------------------------------------------------------------------------- /afew/Database.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import os 5 | import time 6 | import logging 7 | 8 | import notmuch2 9 | 10 | from afew.NotmuchSettings import notmuch_settings, get_notmuch_new_tags 11 | 12 | 13 | class Database: 14 | """ 15 | Convenience wrapper around `notmuch`. 16 | """ 17 | 18 | def __init__(self): 19 | self.db_path = self._calculate_db_path() 20 | self.handle = None 21 | 22 | def _calculate_db_path(self): 23 | """ 24 | Calculates the path to use for the database. Supports notmuch's 25 | methodology including falling back to $MAILDIR or $HOME/mail if a path 26 | is not specified and using $HOME/ if path is relative. 27 | """ 28 | default_path = os.environ.get('MAILDIR', '{}/mail'.format(os.environ.get('HOME'))) 29 | db_path = notmuch_settings.get('database', 'path', fallback=default_path) 30 | 31 | # If path is relative, notmuch prepends $HOME in front 32 | if not os.path.isabs(db_path): 33 | db_path = '{}/{}'.format(os.environ.get('HOME'), db_path) 34 | 35 | return db_path 36 | 37 | def __enter__(self): 38 | """ 39 | Implements the context manager protocol. 40 | """ 41 | return self 42 | 43 | def __exit__(self, exc_type, exc_value, traceback): 44 | """ 45 | Implements the context manager protocol. 46 | """ 47 | self.close() 48 | 49 | def open(self, rw=False, retry_for=180, retry_delay=1): 50 | if rw: 51 | if self.handle and self.handle.mode == notmuch2.Database.MODE.READ_WRITE: 52 | return self.handle 53 | 54 | start_time = time.time() 55 | while True: 56 | try: 57 | self.handle = notmuch2.Database(self.db_path, 58 | mode=notmuch2.Database.MODE.READ_WRITE) 59 | break 60 | except notmuch2.NotmuchError: 61 | time_left = int(retry_for - (time.time() - start_time)) 62 | 63 | if time_left <= 0: 64 | raise 65 | 66 | if time_left % 15 == 0: 67 | logging.debug( 68 | 'Opening the database failed. Will keep trying for another {} seconds'.format(time_left)) 69 | 70 | time.sleep(retry_delay) 71 | else: 72 | if not self.handle: 73 | self.handle = notmuch2.Database(self.db_path, 74 | mode=notmuch2.Database.MODE.READ_ONLY) 75 | 76 | return self.handle 77 | 78 | def close(self): 79 | """ 80 | Closes the notmuch database if it has been opened. 81 | """ 82 | if self.handle: 83 | self.handle.close() 84 | self.handle = None 85 | 86 | def do_query(self, query): 87 | """ 88 | Executes a notmuch query. 89 | 90 | :param query: the query to execute 91 | :type query: str 92 | :returns: the query result 93 | :rtype: :class:`notmuch.Query` 94 | """ 95 | logging.debug('Executing query %r' % query) 96 | return notmuch2.Database.messages(self.open(), query) 97 | 98 | def get_messages(self, query, full_thread=False): 99 | """ 100 | Get all messages matching the given query. 101 | 102 | :param query: the query to execute using :func:`Database.do_query` 103 | :type query: str 104 | :param full_thread: return all messages from mathing threads 105 | :type full_thread: bool 106 | :returns: an iterator over :class:`notmuch.Message` objects 107 | """ 108 | if not full_thread: 109 | for message in self.do_query(query): 110 | yield message 111 | else: 112 | for thread in self.do_query(query): 113 | for message in self.walk_thread(thread): 114 | yield message 115 | 116 | def walk_replies(self, message): 117 | """ 118 | Returns all replies to the given message. 119 | 120 | :param message: the message to start from 121 | :type message: :class:`notmuch.Message` 122 | :returns: an iterator over :class:`notmuch.Message` objects 123 | """ 124 | yield message 125 | 126 | # TODO: bindings are *very* unpythonic here... iterator *or* None 127 | # is a nono 128 | replies = message.get_replies() 129 | if replies is not None: 130 | for message in replies: 131 | # TODO: yield from 132 | for message in self.walk_replies(message): 133 | yield message 134 | 135 | def walk_thread(self, thread): 136 | """ 137 | Returns all messages in the given thread. 138 | 139 | :param thread: the tread you are interested in 140 | :type thread: :class:`notmuch.Thread` 141 | :returns: an iterator over :class:`notmuch.Message` objects 142 | """ 143 | for message in thread.get_toplevel_messages(): 144 | # TODO: yield from 145 | for message in self.walk_replies(message): 146 | yield message 147 | 148 | def add_message(self, path, sync_maildir_flags=False, new_mail_handler=None): 149 | """ 150 | Adds the given message to the notmuch index. 151 | 152 | :param path: path to the message 153 | :type path: str 154 | :param sync_maildir_flags: if `True` notmuch converts the 155 | standard maildir flags to tags 156 | :type sync_maildir_flags: bool 157 | :param new_mail_handler: callback for new messages 158 | :type new_mail_handler: a function that is called with a 159 | :class:`notmuch.Message` object as 160 | its only argument 161 | :raises: :class:`notmuch.NotmuchError` if adding the message fails 162 | :returns: a :class:`notmuch.Message` object 163 | """ 164 | # TODO: it would be nice to update notmuchs directory index here 165 | handle = self.open(rw=True) 166 | message, duplicate = handle.add(path, sync_flags=sync_maildir_flags) 167 | 168 | if not duplicate: 169 | logging.info('Found new mail in {}'.format(path)) 170 | 171 | for tag in get_notmuch_new_tags(): 172 | message.tags.add(tag) 173 | 174 | if new_mail_handler: 175 | new_mail_handler(message) 176 | 177 | return message 178 | 179 | def remove_message(self, path): 180 | """ 181 | Remove the given message from the notmuch index. 182 | 183 | :param path: path to the message 184 | :type path: str 185 | """ 186 | self.open(rw=True).remove_message(path) 187 | -------------------------------------------------------------------------------- /afew/FilterRegistry.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import pkg_resources 5 | 6 | RAISEIT = object() 7 | 8 | 9 | class FilterRegistry: 10 | """ 11 | The FilterRegistry is responsible for returning 12 | filters by key. 13 | Filters get registered via entry points. 14 | To avoid any circular dependencies, the registry loads 15 | the Filters lazily 16 | """ 17 | 18 | def __init__(self, filters): 19 | self._filteriterator = filters 20 | 21 | @property 22 | def filter(self): 23 | if not hasattr(self, '_filter'): 24 | self._filter = {} 25 | for f in self._filteriterator: 26 | self._filter[f.name] = f.load() 27 | return self._filter 28 | 29 | def get(self, key, default=RAISEIT): 30 | if default == RAISEIT: 31 | return self.filter[key] 32 | else: 33 | return self.filter.get(key, default) 34 | 35 | def __getitem__(self, key): 36 | return self.get(key) 37 | 38 | def __setitem__(self, key, value): 39 | self.filter[key] = value 40 | 41 | def __delitem__(self, key): 42 | del self.filter[key] 43 | 44 | def keys(self): 45 | return self.filter.keys() 46 | 47 | def values(self): 48 | return self.filter.values() 49 | 50 | def items(self): 51 | return self.filter.items() 52 | 53 | 54 | all_filters = FilterRegistry(pkg_resources.iter_entry_points('afew.filter')) 55 | 56 | 57 | def register_filter(klass): 58 | '''Decorator function for registering a class as a filter.''' 59 | 60 | all_filters[klass.__name__] = klass 61 | return klass 62 | -------------------------------------------------------------------------------- /afew/MailMover.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) dtk 3 | 4 | import logging 5 | import os 6 | import shutil 7 | import uuid 8 | from datetime import date, datetime, timedelta 9 | from subprocess import check_call, CalledProcessError, DEVNULL 10 | 11 | from afew.Database import Database 12 | from afew.utils import get_message_summary 13 | 14 | 15 | class MailMover(Database): 16 | """ 17 | Move mail files matching a given notmuch query into a target maildir folder. 18 | """ 19 | 20 | def __init__(self, max_age=0, rename=False, dry_run=False, notmuch_args='', quiet=False): 21 | super().__init__() 22 | self.db = Database() 23 | self.query = 'folder:"{folder}" AND {subquery}' 24 | if max_age: 25 | days = timedelta(int(max_age)) 26 | start = date.today() - days 27 | now = datetime.now() 28 | self.query += ' AND {start}..{now}'.format(start=start.strftime('%s'), 29 | now=now.strftime('%s')) 30 | self.dry_run = dry_run 31 | self.rename = rename 32 | self.notmuch_args = notmuch_args 33 | self.quiet = quiet 34 | 35 | def get_new_name(self, fname, destination): 36 | basename = os.path.basename(fname) 37 | submaildir = os.path.split(os.path.split(fname)[0])[1] 38 | if self.rename: 39 | parts = basename.split(':') 40 | if len(parts) > 1: 41 | flagpart = ':' + parts[-1] 42 | else: 43 | flagpart = '' 44 | # construct a new filename, composed of a made-up ID and the flags part 45 | # of the original filename. 46 | basename = str(uuid.uuid1()) + flagpart 47 | return os.path.join(destination, submaildir, basename) 48 | 49 | def move(self, maildir, rules): 50 | """ 51 | Move mails in folder maildir according to the given rules. 52 | """ 53 | # identify and move messages 54 | logging.info("checking mails in '{}'".format(maildir)) 55 | to_delete_fnames = [] 56 | moved = False 57 | for query in rules.keys(): 58 | destination = '{}/{}/'.format(self.db_path, rules[query]) 59 | main_query = self.query.format( 60 | folder=maildir.replace("\"", "\\\""), subquery=query) 61 | logging.debug("query: {}".format(main_query)) 62 | messages = self.db.get_messages(main_query) 63 | for message in messages: 64 | # a single message (identified by Message-ID) can be in several 65 | # places; only touch the one(s) that exists in this maildir 66 | all_message_fnames = (str(name) for name in message.filenames()) 67 | to_move_fnames = [name for name in all_message_fnames 68 | if maildir in name] 69 | if not to_move_fnames: 70 | continue 71 | moved = True 72 | self.__log_move_action(message, maildir, rules[query], 73 | self.dry_run) 74 | for fname in to_move_fnames: 75 | if self.dry_run: 76 | continue 77 | try: 78 | shutil.copy2(fname, self.get_new_name(fname, destination)) 79 | to_delete_fnames.append(fname) 80 | except shutil.SameFileError: 81 | logging.warning("trying to move '{}' onto itself".format(fname)) 82 | continue 83 | except shutil.Error as e: 84 | # this is ugly, but shutil does not provide more 85 | # finely individuated errors 86 | if str(e).endswith("already exists"): 87 | continue 88 | else: 89 | raise 90 | 91 | # remove mail from source locations only after all copies are finished 92 | for fname in set(to_delete_fnames): 93 | os.remove(fname) 94 | 95 | # update notmuch database 96 | if not self.dry_run: 97 | if moved: 98 | logging.info("updating database") 99 | self.__update_db(maildir) 100 | else: 101 | logging.info("Would update database") 102 | 103 | def __update_db(self, maildir): 104 | """ 105 | Update the database after mail files have been moved in the filesystem. 106 | """ 107 | try: 108 | if self.quiet: 109 | check_call(['notmuch', 'new'] + self.notmuch_args.split(), stdout=DEVNULL, stderr=DEVNULL) 110 | else: 111 | check_call(['notmuch', 'new'] + self.notmuch_args.split()) 112 | except CalledProcessError as err: 113 | logging.error("Could not update notmuch database " 114 | "after syncing maildir '{}': {}".format(maildir, err)) 115 | raise SystemExit 116 | 117 | def __log_move_action(self, message, source, destination, dry_run): 118 | ''' 119 | Report which mails have been identified for moving. 120 | ''' 121 | if not dry_run: 122 | level = logging.DEBUG 123 | prefix = 'moving mail' 124 | else: 125 | level = logging.INFO 126 | prefix = 'I would move mail' 127 | logging.log(level, prefix) 128 | logging.log(level, " {}".format(get_message_summary(message).encode('utf8'))) 129 | logging.log(level, "from '{}' to '{}'".format(source, destination)) 130 | -------------------------------------------------------------------------------- /afew/NotmuchSettings.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import os 5 | 6 | from afew.configparser import RawConfigParser 7 | 8 | notmuch_settings = RawConfigParser() 9 | 10 | 11 | def read_notmuch_settings(path=None): 12 | if path is None: 13 | path = os.environ.get('NOTMUCH_CONFIG', os.path.expanduser('~/.notmuch-config')) 14 | 15 | with open(path) as fp: 16 | notmuch_settings.read_file(fp) 17 | 18 | 19 | def write_notmuch_settings(path=None): 20 | if path is None: 21 | path = os.environ.get('NOTMUCH_CONFIG', os.path.expanduser('~/.notmuch-config')) 22 | 23 | with open(path, 'w+') as fp: 24 | notmuch_settings.write(fp) 25 | 26 | 27 | def get_notmuch_new_tags(): 28 | # see issue 158 29 | return filter(lambda x: x != 'unread', notmuch_settings.get_list('new', 'tags')) 30 | 31 | 32 | def get_notmuch_new_query(): 33 | return '(%s)' % ' AND '.join('tag:%s' % tag for tag in get_notmuch_new_tags()) 34 | -------------------------------------------------------------------------------- /afew/Settings.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import os 5 | import re 6 | import collections 7 | import shlex 8 | 9 | from afew.configparser import ConfigParser 10 | from afew.FilterRegistry import all_filters 11 | 12 | user_config_dir = os.path.join(os.environ.get('XDG_CONFIG_HOME', 13 | os.path.expanduser('~/.config')), 14 | 'afew') 15 | user_config_dir = os.path.expandvars(user_config_dir) 16 | 17 | settings = ConfigParser() 18 | # preserve the capitalization of the keys. 19 | settings.optionxform = str 20 | 21 | settings.read_file(open(os.path.join(os.path.dirname(__file__), 'defaults', 'afew.config'))) 22 | settings.read(os.path.join(user_config_dir, 'config')) 23 | 24 | # All the values for keys listed here are interpreted as ;-delimited lists 25 | value_is_a_list = ['tags', 'tags_blacklist'] 26 | mail_mover_section = 'MailMover' 27 | 28 | section_re = re.compile(r'^(?P[a-z_][a-z0-9_]*)(\((?P[a-z_][a-z0-9_]*)\)|\.(?P\d+))?$', re.I) 29 | 30 | 31 | def get_filter_chain(database): 32 | filter_chain = [] 33 | 34 | for section in settings.sections(): 35 | if section == 'global' or section == mail_mover_section: 36 | continue 37 | 38 | match = section_re.match(section) 39 | if not match: 40 | raise SyntaxError('Malformed section title %r.' % section) 41 | 42 | kwargs = dict( 43 | (key, settings.get(section, key)) 44 | if key not in value_is_a_list else 45 | (key, settings.get_list(section, key)) 46 | for key in settings.options(section) 47 | ) 48 | 49 | if match.group('parent_class'): 50 | try: 51 | parent_class = all_filters[match.group('parent_class')] 52 | except KeyError: 53 | raise NameError( 54 | 'Parent class %r not found in filter type definition %r.' % (match.group('parent_class'), section)) 55 | 56 | new_type = type(match.group('name'), (parent_class,), kwargs) 57 | all_filters[match.group('name')] = new_type 58 | else: 59 | try: 60 | klass = all_filters[match.group('name')] 61 | except KeyError: 62 | raise NameError('Filter type %r not found.' % match.group('name')) 63 | filter_chain.append(klass(database, **kwargs)) 64 | 65 | return filter_chain 66 | 67 | 68 | def get_mail_move_rules(): 69 | rule_pattern = re.compile(r"'(.+?)':((?P['\"])(.*?)(?P=quote)|\S+)") 70 | if settings.has_option(mail_mover_section, 'folders'): 71 | all_rules = collections.OrderedDict() 72 | 73 | for folder in shlex.split(settings.get(mail_mover_section, 'folders')): 74 | if settings.has_option(mail_mover_section, folder): 75 | rules = collections.OrderedDict() 76 | raw_rules = re.findall(rule_pattern, 77 | settings.get(mail_mover_section, folder)) 78 | for rule in raw_rules: 79 | query = rule[0] 80 | destination = rule[3] or rule[1] 81 | rules[query] = destination 82 | all_rules[folder] = rules 83 | else: 84 | raise NameError("No rules specified for maildir '{}'.".format(folder)) 85 | 86 | return all_rules 87 | else: 88 | raise NameError("No folders defined to move mails from.") 89 | 90 | 91 | def get_mail_move_age(): 92 | max_age = 0 93 | if settings.has_option(mail_mover_section, 'max_age'): 94 | max_age = settings.get(mail_mover_section, 'max_age') 95 | return max_age 96 | 97 | 98 | def get_mail_move_rename(): 99 | rename = False 100 | if settings.has_option(mail_mover_section, 'rename'): 101 | rename = settings.get(mail_mover_section, 'rename').lower() == 'true' 102 | return rename 103 | -------------------------------------------------------------------------------- /afew/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | -------------------------------------------------------------------------------- /afew/__main__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Lucas Hoffmann 3 | 4 | from afew.commands import main 5 | 6 | main() 7 | -------------------------------------------------------------------------------- /afew/commands.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import glob 5 | import sys 6 | import logging 7 | import argparse 8 | 9 | from afew.Database import Database 10 | from afew.main import main as inner_main 11 | from afew.FilterRegistry import all_filters 12 | from afew.Settings import user_config_dir, get_filter_chain, \ 13 | get_mail_move_rules, get_mail_move_age, get_mail_move_rename 14 | from afew.NotmuchSettings import read_notmuch_settings, get_notmuch_new_query 15 | from afew.version import version 16 | 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument('-V', '--version', action='version', version=version) 19 | 20 | # the actions 21 | action_group = parser.add_argument_group( 22 | 'Actions', 23 | 'Please specify exactly one action.' 24 | ) 25 | action_group.add_argument( 26 | '-t', '--tag', action='store_true', 27 | help='run the tag filters' 28 | ) 29 | action_group.add_argument( 30 | '-w', '--watch', action='store_true', 31 | help='continuously monitor the mailbox for new files' 32 | ) 33 | action_group.add_argument( 34 | '-m', '--move-mails', action='store_true', 35 | help='move mail files between maildir folders' 36 | ) 37 | 38 | # query modifiers 39 | query_modifier_group = parser.add_argument_group( 40 | 'Query modifiers', 41 | 'Please specify either --all or --new or a query string.' 42 | ) 43 | query_modifier_group.add_argument( 44 | '-a', '--all', action='store_true', 45 | help='operate on all messages' 46 | ) 47 | query_modifier_group.add_argument( 48 | '-n', '--new', action='store_true', 49 | help='operate on all new messages' 50 | ) 51 | query_modifier_group.add_argument( 52 | 'query', nargs='*', help='a notmuch query to find messages to work on' 53 | ) 54 | 55 | # general options 56 | options_group = parser.add_argument_group('General options') 57 | # TODO: get config via notmuch api 58 | options_group.add_argument( 59 | '-C', '--notmuch-config', default=None, 60 | help='path to the notmuch configuration file [default: $NOTMUCH_CONFIG or' 61 | ' ~/.notmuch-config]' 62 | ) 63 | options_group.add_argument( 64 | '-e', '--enable-filters', 65 | help="filter classes to use, separated by ',' [default: filters specified" 66 | " in afew's config]" 67 | ) 68 | options_group.add_argument( 69 | '-d', '--dry-run', default=False, action='store_true', 70 | help="don't change the db [default: %(default)s]" 71 | ) 72 | options_group.add_argument( 73 | '-R', '--reference-set-size', type=int, default=1000, 74 | help='size of the reference set [default: %(default)s]' 75 | ) 76 | 77 | options_group.add_argument( 78 | '-T', '--reference-set-timeframe', type=int, default=30, metavar='DAYS', 79 | help='do not use mails older than DAYS days [default: %(default)s]' 80 | ) 81 | 82 | options_group.add_argument( 83 | '-v', '--verbose', dest='verbosity', action='count', default=0, 84 | help='be more verbose, can be given multiple times' 85 | ) 86 | 87 | options_group.add_argument( 88 | '-N', '--notmuch-args', default='', 89 | help='arguments for notmuch new (in move mode)' 90 | ) 91 | 92 | 93 | def main(): 94 | if sys.version_info < (3, 6): 95 | sys.exit("Python 3.6 or later is required.") 96 | 97 | args = parser.parse_args() 98 | 99 | no_actions = len(list(filter(None, ( 100 | args.tag, 101 | args.watch, 102 | args.move_mails 103 | )))) 104 | if no_actions == 0: 105 | sys.exit('You need to specify an action') 106 | elif no_actions > 1: 107 | sys.exit('Please specify exactly one action') 108 | 109 | no_query_modifiers = len(list(filter(None, (args.all, 110 | args.new, args.query)))) 111 | if no_query_modifiers == 0 and not args.watch \ 112 | and not args.move_mails: 113 | sys.exit('You need to specify one of --new, --all or a query string') 114 | elif no_query_modifiers > 1: 115 | sys.exit('Please specify either --all, --new or a query string') 116 | 117 | read_notmuch_settings(args.notmuch_config) 118 | 119 | if args.new: 120 | query_string = get_notmuch_new_query() 121 | elif args.all: 122 | query_string = '' 123 | else: 124 | query_string = ' '.join(args.query) 125 | 126 | loglevel = { 127 | 0: logging.WARNING, 128 | 1: logging.INFO, 129 | 2: logging.DEBUG, 130 | }[min(2, args.verbosity)] 131 | logging.basicConfig(level=loglevel) 132 | 133 | sys.path.insert(0, user_config_dir) 134 | for file_name in glob.glob1(user_config_dir, '*.py'): 135 | logging.info('Importing user filter %r' % (file_name,)) 136 | __import__(file_name[:-3], level=0) 137 | 138 | if args.move_mails: 139 | args.mail_move_rules = get_mail_move_rules() 140 | args.mail_move_age = get_mail_move_age() 141 | args.mail_move_rename = get_mail_move_rename() 142 | 143 | with Database() as database: 144 | configured_filter_chain = get_filter_chain(database) 145 | if args.enable_filters: 146 | args.enable_filters = args.enable_filters.split(',') 147 | 148 | all_filters_set = set(all_filters.keys()) 149 | enabled_filters_set = set(args.enable_filters) 150 | if not all_filters_set.issuperset(enabled_filters_set): 151 | sys.exit('Unknown filter(s) selected: %s' % (' '.join( 152 | enabled_filters_set.difference(all_filters_set)))) 153 | 154 | args.enable_filters = [all_filters[filter_name](database) 155 | for filter_name 156 | in args.enable_filters] 157 | else: 158 | args.enable_filters = configured_filter_chain 159 | 160 | inner_main(args, database, query_string) 161 | -------------------------------------------------------------------------------- /afew/configparser.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import configparser 5 | 6 | 7 | class GetListMixIn: 8 | def get_list(self, section, key, delimiter=';', 9 | filter_=lambda value: value.strip(), 10 | include_falsish=False): 11 | result = (filter_(value) 12 | for value in self.get(section, key).split(delimiter)) 13 | 14 | if include_falsish: 15 | return result 16 | else: 17 | return filter(None, result) 18 | 19 | 20 | class ConfigParser(configparser.ConfigParser, GetListMixIn): 21 | pass 22 | 23 | 24 | class RawConfigParser(configparser.RawConfigParser, GetListMixIn): 25 | pass 26 | -------------------------------------------------------------------------------- /afew/defaults/afew.config: -------------------------------------------------------------------------------- 1 | # global configuration 2 | [global] 3 | 4 | #[MailMover] 5 | #folders = INBOX Junk 6 | #max_age = 15 7 | 8 | ##rules 9 | #INBOX = 'tag:spam':Junk 'NOT tag:inbox':Archive 10 | #Junk = 'NOT tag:spam and tag:inbox':INBOX 'NOT tag:spam':Archive 11 | 12 | # This is the default filter chain 13 | #[SpamFilter] 14 | #[KillThreadsFilter] 15 | #[ListMailsFilter] 16 | #[ArchiveSentMailsFilter] 17 | #[InboxFilter] 18 | 19 | # Let's say you like the SpamFilter, but it is way too polite 20 | 21 | # 1. create an filter object and customize it 22 | #[SpamFilter.0] # note the index 23 | #message = meh 24 | 25 | # 2. create a new type and... 26 | #[ShitFilter(SpamFilter)] 27 | #message = I hatez teh spam! 28 | 29 | # create an object or two... 30 | #[ShitFilter.0] 31 | #[ShitFilter.1] 32 | #message = Me hatez it too. 33 | 34 | # 3. drop a custom filter type in ~/.config/afew/ 35 | #[MyCustomFilter] 36 | 37 | 38 | # To create a custom generic filter, define it inline with 39 | # your above filter chain. E.g.: 40 | 41 | # ... 42 | # [ListMailsFilter] 43 | # 44 | # [Filter.1] 45 | # query = from:boss@office.com 46 | # tags = +office 47 | # 48 | # [ArchiveSentMailsFilter] 49 | # ... 50 | -------------------------------------------------------------------------------- /afew/files.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import os 5 | import re 6 | import stat 7 | import logging 8 | import platform 9 | import queue 10 | import threading 11 | import notmuch2 12 | import pyinotify 13 | import ctypes 14 | import contextlib 15 | 16 | if platform.system() != 'Linux': 17 | raise ImportError('Unsupported platform: {!r}'.format(platform.system())) 18 | 19 | 20 | class EventHandler(pyinotify.ProcessEvent): 21 | def __init__(self, options, database): 22 | self.options = options 23 | self.database = database 24 | super().__init__() 25 | 26 | ignore_re = re.compile(r'(/xapian/.*(base.|tmp)$)|(\.lock$)|(/dovecot)') 27 | 28 | def process_IN_DELETE(self, event): 29 | if self.ignore_re.search(event.pathname): 30 | return 31 | 32 | logging.debug("Detected file removal: {!r}".format(event.pathname)) 33 | self.database.remove_message(event.pathname) 34 | self.database.close() 35 | 36 | def process_IN_MOVED_TO(self, event): 37 | if self.ignore_re.search(event.pathname): 38 | return 39 | 40 | src_pathname = event.src_pathname if hasattr(event, 'src_pathname') else None 41 | logging.debug("Detected file rename: {!r} -> {!r}".format(src_pathname, event.pathname)) 42 | 43 | def new_mail(message): 44 | for filter_ in self.options.enable_filters: 45 | try: 46 | filter_.run('id:"{}"'.format(message.messageid)) 47 | filter_.commit(self.options.dry_run) 48 | except Exception as e: 49 | logging.warning('Error processing mail with filter {!r}: {}'.format(filter_.message, e)) 50 | 51 | try: 52 | self.database.add_message(event.pathname, 53 | sync_flags=True, 54 | new_mail_handler=new_mail) 55 | except notmuch2.FileError as e: 56 | logging.warning('Error opening mail file: {}'.format(e)) 57 | return 58 | except notmuch2.FileNotEmailError as e: 59 | logging.warning('File does not look like an email: {}'.format(e)) 60 | return 61 | else: 62 | if src_pathname: 63 | self.database.remove_message(src_pathname) 64 | finally: 65 | self.database.close() 66 | 67 | 68 | def watch_for_new_files(options, database, paths, daemonize=False): 69 | wm = pyinotify.WatchManager() 70 | mask = ( 71 | pyinotify.IN_DELETE | 72 | pyinotify.IN_MOVED_FROM | 73 | pyinotify.IN_MOVED_TO) 74 | handler = EventHandler(options, database) 75 | notifier = pyinotify.Notifier(wm, handler) 76 | 77 | logging.debug('Registering inotify watch descriptors') 78 | wdds = dict() 79 | for path in paths: 80 | wdds[path] = wm.add_watch(path, mask) 81 | 82 | # TODO: honor daemonize 83 | logging.debug('Running mainloop') 84 | notifier.loop() 85 | 86 | 87 | try: 88 | libc = ctypes.CDLL(ctypes.util.find_library("c")) 89 | except ImportError as e: 90 | raise ImportError('Could not load libc: {}'.format(e)) 91 | 92 | 93 | class Libc: 94 | class c_dir(ctypes.Structure): 95 | pass 96 | 97 | c_dir_p = ctypes.POINTER(c_dir) 98 | 99 | opendir = libc.opendir 100 | opendir.argtypes = [ctypes.c_char_p] 101 | opendir.restype = c_dir_p 102 | 103 | closedir = libc.closedir 104 | closedir.argtypes = [c_dir_p] 105 | closedir.restype = ctypes.c_int 106 | 107 | @classmethod 108 | @contextlib.contextmanager 109 | def open_directory(cls, path): 110 | handle = cls.opendir(path) 111 | yield handle 112 | cls.closedir(handle) 113 | 114 | class c_dirent(ctypes.Structure): 115 | ''' 116 | man 3 readdir says:: 117 | 118 | On Linux, the dirent structure is defined as follows: 119 | 120 | struct dirent { 121 | ino_t d_ino; /* inode number */ 122 | off_t d_off; /* offset to the next dirent */ 123 | unsigned short d_reclen; /* length of this record */ 124 | unsigned char d_type; /* type of file; not supported 125 | by all file system types */ 126 | char d_name[256]; /* filename */ 127 | }; 128 | ''' 129 | _fields_ = ( 130 | ('d_ino', ctypes.c_long), 131 | ('d_off', ctypes.c_long), 132 | ('d_reclen', ctypes.c_ushort), 133 | ('d_type', ctypes.c_byte), 134 | ('d_name', ctypes.c_char * 4096), 135 | ) 136 | 137 | c_dirent_p = ctypes.POINTER(c_dirent) 138 | 139 | readdir = libc.readdir 140 | readdir.argtypes = [c_dir_p] 141 | readdir.restype = c_dirent_p 142 | 143 | # magic value for directory 144 | DT_DIR = 4 145 | 146 | 147 | blacklist = {'.', '..', 'tmp'} 148 | 149 | 150 | def walk_linux(channel, path): 151 | channel.put(path) 152 | 153 | with Libc.open_directory(path) as handle: 154 | while True: 155 | dirent_p = Libc.readdir(handle) 156 | if not dirent_p: 157 | break 158 | 159 | if dirent_p.contents.d_type == Libc.DT_DIR and \ 160 | dirent_p.contents.d_name not in blacklist: 161 | walk_linux(channel, os.path.join(path, dirent_p.contents.d_name)) 162 | 163 | 164 | def walk(channel, path): 165 | channel.put(path) 166 | 167 | for child_path in (os.path.join(path, child) 168 | for child in os.listdir(path) 169 | if child not in blacklist): 170 | try: 171 | stat_result = os.stat(child_path) 172 | except Exception: 173 | continue 174 | 175 | if stat_result.st_mode & stat.S_IFDIR: 176 | walk(channel, child_path) 177 | 178 | 179 | def walker(channel, path): 180 | walk_linux(channel, path) 181 | channel.put(None) 182 | 183 | 184 | def quick_find_dirs_hack(path): 185 | results = queue.Queue() 186 | 187 | walker_thread = threading.Thread(target=walker, args=(results, path)) 188 | walker_thread.daemon = True 189 | walker_thread.start() 190 | 191 | while True: 192 | result = results.get() 193 | 194 | if result is not None: 195 | yield result 196 | else: 197 | break 198 | -------------------------------------------------------------------------------- /afew/filters/ArchiveSentMailsFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | from afew.filters.SentMailsFilter import SentMailsFilter 5 | from afew.NotmuchSettings import get_notmuch_new_tags 6 | 7 | 8 | class ArchiveSentMailsFilter(SentMailsFilter): 9 | message = 'Archiving all mails sent by myself to others' 10 | 11 | def __init__(self, database, sent_tag='', to_transforms=''): 12 | super().__init__(database, sent_tag) 13 | 14 | def handle_message(self, message): 15 | super().handle_message(message) 16 | self.remove_tags(message, *get_notmuch_new_tags()) 17 | -------------------------------------------------------------------------------- /afew/filters/BaseFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import collections 5 | import logging 6 | 7 | 8 | class Filter: 9 | message = 'No message specified for filter' 10 | tags = [] 11 | tags_blacklist = [] 12 | 13 | def __init__(self, database, **kwargs): 14 | super().__init__() 15 | 16 | self.log = logging.getLogger('{}.{}'.format( 17 | self.__module__, self.__class__.__name__)) 18 | 19 | self.database = database 20 | if 'tags' not in kwargs: 21 | kwargs['tags'] = self.tags 22 | for key, value in kwargs.items(): 23 | setattr(self, key, value) 24 | 25 | self.flush_changes() 26 | self._tags_to_add = [] 27 | self._tags_to_remove = [] 28 | for tag_action in self.tags: 29 | if tag_action[0] not in '+-': 30 | raise ValueError('Each tag must be preceded by either + or -') 31 | 32 | (self._tags_to_add if tag_action[0] == '+' else self._tags_to_remove).append(tag_action[1:]) 33 | 34 | self._tag_blacklist = set(self.tags_blacklist) 35 | 36 | def flush_changes(self): 37 | ''' 38 | (Re)Initializes the data structures that hold the enqueued 39 | changes to the notmuch database. 40 | ''' 41 | self._add_tags = collections.defaultdict(lambda: set()) 42 | self._remove_tags = collections.defaultdict(lambda: set()) 43 | self._flush_tags = [] 44 | 45 | def run(self, query): 46 | self.log.info(self.message) 47 | 48 | if getattr(self, 'query', None): 49 | if query: 50 | query = '(%s) AND (%s)' % (query, self.query) 51 | else: 52 | query = self.query 53 | 54 | for message in self.database.get_messages(query): 55 | self.handle_message(message) 56 | 57 | def handle_message(self, message): 58 | if not self._tag_blacklist.intersection(message.tags): 59 | self.remove_tags(message, *self._tags_to_remove) 60 | self.add_tags(message, *self._tags_to_add) 61 | 62 | def add_tags(self, message, *tags): 63 | if tags: 64 | self.log.debug('Adding tags %s to id:%s' % (', '.join(tags), 65 | message.messageid)) 66 | self._add_tags[message.messageid].update(tags) 67 | 68 | def remove_tags(self, message, *tags): 69 | if tags: 70 | filtered_tags = list(tags) 71 | self.log.debug('Removing tags %s from id:%s' % (', '.join(filtered_tags), 72 | message.messageid)) 73 | self._remove_tags[message.messageid].update(filtered_tags) 74 | 75 | def flush_tags(self, message): 76 | self.log.debug('Removing all tags from id:%s' % 77 | message.messageid) 78 | self._flush_tags.append(message.messageid) 79 | 80 | def commit(self, dry_run=True): 81 | dirty_messages = set() 82 | dirty_messages.update(self._flush_tags) 83 | dirty_messages.update(self._add_tags.keys()) 84 | dirty_messages.update(self._remove_tags.keys()) 85 | 86 | if not dirty_messages: 87 | return 88 | 89 | if dry_run: 90 | self.log.info('I would commit changes to %i messages' % len(dirty_messages)) 91 | else: 92 | self.log.info('Committing changes to %i messages' % len(dirty_messages)) 93 | db = self.database.open(rw=True) 94 | 95 | for message_id in dirty_messages: 96 | message = db.find(message_id) 97 | 98 | if message_id in self._flush_tags: 99 | message.remove_all_tags() 100 | 101 | for tag in self._add_tags.get(message_id, []): 102 | message.tags.add(tag) 103 | 104 | for tag in self._remove_tags.get(message_id, []): 105 | try: 106 | message.tags.remove(tag) 107 | except KeyError: 108 | pass 109 | 110 | self.flush_changes() 111 | -------------------------------------------------------------------------------- /afew/filters/DKIMValidityFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Amadeusz Zolnowski 3 | 4 | """ 5 | DKIM validator filter. 6 | 7 | Verifies DKIM signature of an e-mail which has DKIM header. 8 | """ 9 | 10 | import logging 11 | 12 | import dkim 13 | import dns.exception 14 | 15 | from afew.filters.BaseFilter import Filter 16 | 17 | 18 | class DKIMVerifyError(Exception): 19 | """Failed to verify DKIM signature. 20 | """ 21 | 22 | 23 | def verify_dkim(path): 24 | """ 25 | Verify DKIM signature of an e-mail file. 26 | 27 | :param path: Path to the e-mail file. 28 | :returns: Whether DKIM signature is valid or not. 29 | """ 30 | with open(path, 'rb') as message_file: 31 | message_bytes = message_file.read() 32 | 33 | try: 34 | return dkim.verify(message_bytes) 35 | except (dns.exception.DNSException, dkim.DKIMException) as exception: 36 | raise DKIMVerifyError(str(exception)) from exception 37 | 38 | 39 | class DKIMValidityFilter(Filter): 40 | """ 41 | Verifies DKIM signature of an e-mail which has DKIM header. 42 | """ 43 | message = 'Verify DKIM signature' 44 | header = 'DKIM-Signature' 45 | 46 | def __init__(self, database, ok_tag='dkim-ok', fail_tag='dkim-fail'): 47 | super().__init__(database) 48 | self.dkim_tag = {True: ok_tag, False: fail_tag} 49 | self.log = logging.getLogger('{}.{}'.format( 50 | self.__module__, self.__class__.__name__)) 51 | 52 | def handle_message(self, message): 53 | try: 54 | selfhead = message.header(self.header) 55 | except LookupError: 56 | selfhead = '' 57 | if selfhead: 58 | try: 59 | dkim_ok = all(map(verify_dkim, message.filenames())) 60 | except DKIMVerifyError as verify_error: 61 | self.log.warning( 62 | "Failed to verify DKIM of '%s': %s " 63 | "(marked as 'dkim-fail')", 64 | message.messageid, 65 | verify_error 66 | ) 67 | dkim_ok = False 68 | self.add_tags(message, self.dkim_tag[dkim_ok]) 69 | -------------------------------------------------------------------------------- /afew/filters/DMARCReportInspectionFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Amadeusz Zolnowski 3 | 4 | """ 5 | DMARC report inspection filter. 6 | 7 | Looks into DMARC report whether all results are successful or any is failing. 8 | Add tags 2 of the tags below: 9 | - dmarc/dkim-ok 10 | - dmarc/dkim-fail 11 | - dmarc/spf-ok 12 | - dmarc/spf-fail 13 | 14 | """ 15 | 16 | import logging 17 | import re 18 | import tempfile 19 | import xml.etree.ElementTree as ET 20 | import zipfile 21 | 22 | from .BaseFilter import Filter 23 | 24 | 25 | class DMARCInspectionError(Exception): 26 | """Failed to inspect DMARC report. 27 | """ 28 | 29 | 30 | class ReportFilesIterator: 31 | """ 32 | Iterator over DMARC reports files attached to the e-mail either directly or 33 | in ZIP files. 34 | 35 | Returns content of each document file (as bytes, not as string) which needs 36 | to be decoded from charset encoding. 37 | """ 38 | def __init__(self, message): 39 | self.message = message 40 | 41 | def __iter__(self): 42 | for part in self.message.get_message_parts(): 43 | if part.get_content_type() == 'application/zip': 44 | with tempfile.TemporaryFile(suffix='.zip') as file: 45 | file.write(part.get_payload(decode=True)) 46 | try: 47 | with zipfile.ZipFile(file) as zip_file: 48 | for member_file in zip_file.infolist(): 49 | if member_file.filename.endswith('.xml'): 50 | yield zip_file.read(member_file) 51 | except zipfile.BadZipFile as zip_error: 52 | raise DMARCInspectionError(str(zip_error)) \ 53 | from zip_error 54 | elif part.get_content_type() == 'application/xml': 55 | yield part.get_payload(decode=True) 56 | 57 | 58 | def and_dict(dict1, dict2): 59 | """ 60 | Apply logical conjunction between values of dictionaries of the same keys. 61 | 62 | Keys set must be identical in both dictionaries. Otherwise KeyError 63 | exception is raised. 64 | 65 | :param dict1: Dictionary of bool values. 66 | :param dict2: Dictionary of bool values. 67 | :returns: A dictionary with the same set of keys but with modified values. 68 | """ 69 | dict3 = {} 70 | for key in dict1.keys(): 71 | dict3[key] = dict1[key] & dict2.get(key, False) 72 | return dict3 73 | 74 | 75 | def has_failed(node): 76 | """ 77 | Check whether status is "failed". 78 | 79 | To avoid false positives check whether status is one of "pass" or "none". 80 | 81 | :param node: XML node holding status as text. 82 | :returns: Whether the status is reported as "failed". 83 | """ 84 | if not node or not node.text: 85 | return True 86 | return (node.text.strip() not in ['pass', 'none']) 87 | 88 | 89 | def read_auth_results(document): 90 | """ 91 | Parse DMARC document. 92 | 93 | Look for results for DKIM and SPF. If there's more than one record, return 94 | `True` only and only if all of the records of particular type (DKIM or SPF) 95 | are "pass". 96 | 97 | :returns: Results as a dictionary where keys are: `dkim` and `spf` and 98 | values are boolean values. 99 | """ 100 | try: 101 | results = {'dkim': True, 'spf': True} 102 | root = ET.fromstring(document) 103 | for record in root.findall('record'): 104 | auth_results = record.find('auth_results') 105 | if auth_results: 106 | dkim = auth_results.find('dkim') 107 | if dkim: 108 | dkim = dkim.find('result') 109 | results['dkim'] &= not has_failed(dkim) 110 | spf = auth_results.find('spf') 111 | if spf: 112 | spf = spf.find('result') 113 | results['spf'] &= not has_failed(spf) 114 | except ET.ParseError as parse_error: 115 | raise DMARCInspectionError(str(parse_error)) from parse_error 116 | 117 | return results 118 | 119 | 120 | class DMARCReportInspectionFilter(Filter): 121 | """ 122 | Inspect DMARC reports for DKIM and SPF status. 123 | 124 | Config: 125 | 126 | [DMARCReportInspectionFilter] 127 | dkim_ok_tag = "dmarc/dkim-ok" 128 | dkim_fail_tag = "dmarc/dkim-fail" 129 | spf_ok_tag = "dmarc/spf-ok" 130 | spf_fail_tag = "dmarc/spf-fail" 131 | subject_regexp = "^report domain:" 132 | 133 | """ 134 | def __init__(self, # pylint: disable=too-many-arguments 135 | database, 136 | dkim_ok_tag='dmarc/dkim-ok', 137 | dkim_fail_tag='dmarc/dkim-fail', 138 | spf_ok_tag='dmarc/spf-ok', 139 | spf_fail_tag='dmarc/spf-fail', 140 | subject_regexp=r'^report domain:'): 141 | super().__init__(database) 142 | self.dkim_tag = {True: dkim_ok_tag, False: dkim_fail_tag} 143 | self.spf_tag = {True: spf_ok_tag, False: spf_fail_tag} 144 | self.dmarc_subject = re.compile(subject_regexp, 145 | flags=re.IGNORECASE) 146 | self.log = logging.getLogger('{}.{}'.format( 147 | self.__module__, self.__class__.__name__)) 148 | 149 | def handle_message(self, message): 150 | if not self.dmarc_subject.match(message.header('Subject')): 151 | return 152 | 153 | auth_results = {'dkim': True, 'spf': True} 154 | 155 | try: 156 | for file_content in ReportFilesIterator(message): 157 | document = file_content.decode('UTF-8') 158 | auth_results = and_dict(auth_results, 159 | read_auth_results(document)) 160 | 161 | self.add_tags(message, 162 | 'dmarc', 163 | self.dkim_tag[auth_results['dkim']], 164 | self.spf_tag[auth_results['spf']]) 165 | except DMARCInspectionError as inspection_error: 166 | self.log.error( 167 | "Failed to verify DMARC report of '%s': %s (not tagging)", 168 | message.messageid, 169 | inspection_error 170 | ) 171 | -------------------------------------------------------------------------------- /afew/filters/FolderNameFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) dtk 3 | 4 | from afew.filters.BaseFilter import Filter 5 | import re 6 | import shlex 7 | 8 | 9 | class FolderNameFilter(Filter): 10 | message = 'Tags all new messages with their folder' 11 | 12 | def __init__(self, database, folder_blacklist='', folder_transforms='', 13 | maildir_separator='.', folder_explicit_list='', folder_lowercases=''): 14 | super().__init__(database) 15 | 16 | self.__filename_pattern = '{mail_root}/(?P.*)/(cur|new)/[^/]+'.format( 17 | mail_root=database.db_path.rstrip('/')) 18 | self.__folder_explicit_list = set(shlex.split(folder_explicit_list)) 19 | self.__folder_blacklist = set(shlex.split(folder_blacklist)) 20 | self.__folder_transforms = self.__parse_transforms(folder_transforms) 21 | self.__folder_lowercases = folder_lowercases != '' 22 | self.__maildir_separator = maildir_separator 23 | 24 | def handle_message(self, message): 25 | # Find all the dirs in the mail directory that this message 26 | # belongs to 27 | maildirs = [re.match(self.__filename_pattern, str(filename)) 28 | for filename in message.filenames()] 29 | maildirs = filter(None, maildirs) 30 | if maildirs: 31 | # Make the folders relative to mail_root and split them. 32 | folder_groups = [maildir.group('maildirs').split(self.__maildir_separator) 33 | for maildir in maildirs] 34 | folders = set([folder 35 | for folder_group in folder_groups 36 | for folder in folder_group]) 37 | try: 38 | subject = message.header('subject') 39 | except LookupError: 40 | subject = '' 41 | self.log.debug('found folders {} for message {!r}'.format( 42 | folders, subject)) 43 | 44 | # remove blacklisted folders 45 | clean_folders = folders - self.__folder_blacklist 46 | if self.__folder_explicit_list: 47 | # only explicitly listed folders 48 | clean_folders &= self.__folder_explicit_list 49 | # apply transformations 50 | transformed_folders = self.__transform_folders(clean_folders) 51 | 52 | self.add_tags(message, *transformed_folders) 53 | 54 | def __transform_folders(self, folders): 55 | """ 56 | Transforms the given collection of folders according to the transformation rules. 57 | """ 58 | transformations = set() 59 | for folder in folders: 60 | if folder in self.__folder_transforms: 61 | transformations.add(self.__folder_transforms[folder]) 62 | else: 63 | transformations.add(folder) 64 | if self.__folder_lowercases: 65 | rtn = set() 66 | for folder in transformations: 67 | rtn.add(folder.lower()) 68 | return rtn 69 | return transformations 70 | 71 | def __parse_transforms(self, transformation_description): 72 | """ 73 | Parses the transformation rules specified in the config file. 74 | """ 75 | transformations = dict() 76 | for rule in shlex.split(transformation_description): 77 | folder, tag = rule.split(':') 78 | transformations[folder] = tag 79 | return transformations 80 | -------------------------------------------------------------------------------- /afew/filters/HeaderMatchingFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) 2012 Justus Winter <4winter@informatik.uni-hamburg.de> 3 | # Copyright (c) 2013 Patrick Gerken 4 | # Copyright (c) 2013 Patrick Totzke 5 | # Copyright (c) 2014 Lars Kellogg-Stedman 6 | 7 | from afew.filters.BaseFilter import Filter 8 | 9 | from notmuch2._errors import NullPointerError 10 | 11 | import re 12 | 13 | 14 | class HeaderMatchingFilter(Filter): 15 | message = 'Tagging based on specific header values matching a given RE' 16 | header = None 17 | pattern = None 18 | 19 | def __init__(self, database, **kwargs): 20 | super().__init__(database, **kwargs) 21 | if self.pattern is not None: 22 | self.pattern = re.compile(self.pattern, re.I) 23 | 24 | def handle_message(self, message): 25 | if self.header is not None and self.pattern is not None: 26 | if not self._tag_blacklist.intersection(message.tags): 27 | try: 28 | value = message.header(self.header) 29 | match = self.pattern.search(value) 30 | if match: 31 | tagdict = {k: v.lower() for k, v in match.groupdict().items()} 32 | sub = (lambda tag: tag.format(**tagdict)) 33 | self.remove_tags(message, *map(sub, self._tags_to_remove)) 34 | self.add_tags(message, *map(sub, self._tags_to_add)) 35 | except (NullPointerError, LookupError): 36 | pass 37 | -------------------------------------------------------------------------------- /afew/filters/InboxFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | from afew.filters.BaseFilter import Filter 5 | from afew.NotmuchSettings import get_notmuch_new_tags, get_notmuch_new_query 6 | 7 | 8 | class InboxFilter(Filter): 9 | message = 'Retags all messages not tagged as junk or killed as inbox' 10 | tags = ['+inbox'] 11 | tags_blacklist = ['killed', 'spam'] 12 | 13 | @property 14 | def query(self): 15 | ''' 16 | Need to read the notmuch settings first. Using a property here 17 | so that the setting is looked up on demand. 18 | ''' 19 | return get_notmuch_new_query() 20 | 21 | def handle_message(self, message): 22 | self.remove_tags(message, *get_notmuch_new_tags()) 23 | super().handle_message(message) 24 | -------------------------------------------------------------------------------- /afew/filters/KillThreadsFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | from afew.filters.BaseFilter import Filter 5 | 6 | 7 | class KillThreadsFilter(Filter): 8 | message = 'Looking for messages in killed threads that are not yet killed' 9 | query = 'NOT tag:killed' 10 | 11 | def handle_message(self, message): 12 | query = self.database.get_messages('thread:"%s" AND tag:killed' % message.threadid) 13 | 14 | if len(list(query)): 15 | self.add_tags(message, 'killed') 16 | -------------------------------------------------------------------------------- /afew/filters/ListMailsFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | from afew.filters.HeaderMatchingFilter import HeaderMatchingFilter 5 | 6 | 7 | class ListMailsFilter(HeaderMatchingFilter): 8 | message = 'Tagging mailing list posts' 9 | query = 'NOT tag:lists' 10 | pattern = r"<(?P[a-z0-9!#$%&'*+/=?^_`{|}~-]+)\." 11 | header = 'List-Id' 12 | tags = ['+lists', '+lists/{list_id}'] 13 | -------------------------------------------------------------------------------- /afew/filters/MeFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Amadeusz Zolnowski 3 | 4 | import re 5 | 6 | from afew.filters.BaseFilter import Filter 7 | from afew.NotmuchSettings import notmuch_settings 8 | 9 | 10 | class MeFilter(Filter): 11 | message = 'Tagging all mails sent directly to myself' 12 | _bare_email_re = re.compile(r"[^<]*<(?P[^@<>]+@[^@<>]+)>") 13 | 14 | def __init__(self, database, me_tag='to-me', tags_blacklist=[]): 15 | super().__init__(database, tags_blacklist=tags_blacklist) 16 | 17 | my_addresses = set() 18 | my_addresses.add(notmuch_settings.get('user', 'primary_email')) 19 | if notmuch_settings.has_option('user', 'other_email'): 20 | for other_email in notmuch_settings.get_list('user', 'other_email'): 21 | my_addresses.add(other_email) 22 | 23 | self.query = ' OR '.join('to:"%s"' % address 24 | for address in my_addresses) 25 | 26 | self.me_tag = me_tag 27 | 28 | def handle_message(self, message): 29 | if not self._tag_blacklist.intersection(message.tags): 30 | self.add_tags(message, self.me_tag) 31 | -------------------------------------------------------------------------------- /afew/filters/PropagateTagsByRegexInThreadFilter.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jens Neuhalfen 2 | 3 | import re 4 | from itertools import chain 5 | 6 | from afew.filters.BaseFilter import Filter 7 | 8 | 9 | def _flatten(listOfLists): 10 | "Flatten one level of nesting" 11 | return chain.from_iterable(listOfLists) 12 | 13 | 14 | class PropagateTagsByRegexInThreadFilter(Filter): 15 | """ 16 | This filter enables a very easy workflow where entire threads can be tagged automatically. 17 | 18 | Assuming the following workflow: all messages for projects or releases should be tagged 19 | as "project/A", "project/B" respectively "release/1.0.1" or "release/1.2.0". 20 | 21 | In most cases replies to messages retain their context: the project, the release(s), .. 22 | 23 | The following config will propagate all project/... or release/... tags from a thread 24 | to all new messages. 25 | 26 | [PropagateTagsByRegexInThreadFilter.1] 27 | propagate_tags = project/.* 28 | # do not tag spam 29 | filter = not is:spam 30 | 31 | [PropagateTagsByRegexInThreadFilter.2] 32 | propagate_tags = release/.* 33 | 34 | Implementation spec: This filter will search through all (new) messages matched by ``filter``. 35 | For each message ``m`` found it goes through the messages thread an collects all assigned 36 | tags that match the regexp ``propagate_tags`` (``t``). 37 | 38 | All matching tags ``t`` are then assigned to the new message. 39 | """ 40 | 41 | def handle_message(self, message): 42 | thread_query = 'thread:"%s"' % (message.threadid,) 43 | if self._filter: 44 | query = self.database.get_messages("(%s) AND (%s)" % (thread_query, self._filter)) 45 | else: 46 | query = self.database.get_messages(thread_query) 47 | 48 | # the query can only be iterated once, then it is exhausted 49 | # https://git.notmuchmail.org/git?p=notmuch;a=blob;f=bindings/python/notmuch/messages.py;h=cae5da508f353f12cca585cb056c0b9ed92e29b3;hb=HEAD 50 | messages = list(query) 51 | 52 | # flatten tags 53 | tags_in_thread_t = {m.tags for m in messages} # a set of Tags instances 54 | tags_in_thread = set(_flatten(tags_in_thread_t)) 55 | 56 | # filter tags 57 | propagatable_tags_in_thread = {tag for tag in tags_in_thread if self._propagate_tags.fullmatch(tag)} 58 | 59 | if len(propagatable_tags_in_thread): 60 | self.add_tags(message, *propagatable_tags_in_thread) 61 | 62 | def __init__(self, database, propagate_tags, filter=None, **kwargs): 63 | if filter: 64 | self.message = "Propagating tag(s) matching regexp /%s/ from threads to (new) messages matching '%s'" % ( 65 | propagate_tags, filter) 66 | else: 67 | self.message = "Propagating tag(s) matching regexp /%s/ from threads to (new) messages'" % ( 68 | propagate_tags,) 69 | 70 | self._filter = filter 71 | self._propagate_tags = re.compile(propagate_tags) 72 | 73 | super(PropagateTagsByRegexInThreadFilter, self).__init__(database, **kwargs) 74 | -------------------------------------------------------------------------------- /afew/filters/PropagateTagsInThreadFilter.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jens Neuhalfen 2 | 3 | from afew.filters.BaseFilter import Filter 4 | 5 | 6 | class PropagateTagsInThreadFilter(Filter): 7 | """ 8 | Propagate tags in threads. For each new message the mail thread is examined. If one of the 9 | configured tags is found, it is automatically attached to the new message. 10 | 11 | This enables a very easy workflow where entire threads can be tagged automatically. 12 | 13 | Config: 14 | 15 | [PropagateTagsInThreadFilter] 16 | propagate_tags = "project_A;billing;private" 17 | filter = not is:spam 18 | 19 | """ 20 | 21 | def handle_message(self, message): 22 | for tag in self._propagate_tags: 23 | tag_query = 'thread:"%s" AND is:"%s"' % (message.threadid, tag) 24 | if self._filter: 25 | query = self.database.get_messages("(%s) AND (%s)" % (tag_query, self._filter)) 26 | else: 27 | query = self.database.get_messages(tag_query) 28 | 29 | if len(list(query)): 30 | self.add_tags(message, tag) 31 | 32 | def __init__(self, database, propagate_tags="", filter=None, **kwargs): 33 | if filter: 34 | self.message = "Propagating tag(s) '%s' for messages matching '%s' to whole threads" % ( 35 | propagate_tags, filter) 36 | else: 37 | self.message = "Propagating tag(s) '%s' to whole threads" % (propagate_tags,) 38 | 39 | self._filter = filter 40 | self._propagate_tags = [t for t in propagate_tags.split(";") if len(t) > 0] 41 | 42 | super(PropagateTagsInThreadFilter, self).__init__(database, **kwargs) 43 | -------------------------------------------------------------------------------- /afew/filters/SentMailsFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import re 5 | 6 | from afew.filters.BaseFilter import Filter 7 | from afew.NotmuchSettings import notmuch_settings 8 | 9 | 10 | class SentMailsFilter(Filter): 11 | message = 'Tagging all mails sent by myself to others' 12 | _bare_email_re = re.compile(r"[^<]*<(?P[^@<>]+@[^@<>]+)>") 13 | 14 | def __init__(self, database, sent_tag='', to_transforms=''): 15 | super().__init__(database) 16 | 17 | my_addresses = set() 18 | my_addresses.add(notmuch_settings.get('user', 'primary_email')) 19 | if notmuch_settings.has_option('user', 'other_email'): 20 | for other_email in notmuch_settings.get_list('user', 'other_email'): 21 | my_addresses.add(other_email) 22 | 23 | self.query = ( 24 | '(' + 25 | ' OR '.join('from:"%s"' % address for address in my_addresses) + 26 | ') AND NOT (' + 27 | ' OR '.join('to:"%s"' % address for address in my_addresses) + 28 | ')' 29 | ) 30 | 31 | self.sent_tag = sent_tag 32 | self.to_transforms = to_transforms 33 | if to_transforms: 34 | self.__email_to_tags = self.__build_email_to_tags(to_transforms) 35 | 36 | def handle_message(self, message): 37 | if self.sent_tag: 38 | self.add_tags(message, self.sent_tag) 39 | if self.to_transforms: 40 | for header in ('To', 'Cc', 'Bcc'): 41 | try: 42 | email = self.__get_bare_email(message.header(header)) 43 | except LookupError: 44 | email = '' 45 | for tag in self.__pick_tags(email): 46 | self.add_tags(message, tag) 47 | else: 48 | break 49 | 50 | def __build_email_to_tags(self, to_transforms): 51 | email_to_tags = dict() 52 | 53 | for rule in to_transforms.split(): 54 | if ':' in rule: 55 | email, tags = rule.split(':') 56 | email_to_tags[email] = tuple(tags.split(';')) 57 | else: 58 | email = rule 59 | email_to_tags[email] = tuple() 60 | 61 | return email_to_tags 62 | 63 | def __get_bare_email(self, email): 64 | if '<' not in email: 65 | return email 66 | else: 67 | match = self._bare_email_re.search(email) 68 | return match.group('email') 69 | 70 | def __pick_tags(self, email): 71 | if email in self.__email_to_tags: 72 | tags = self.__email_to_tags[email] 73 | if tags: 74 | return tags 75 | else: 76 | user_part, domain_part = email.split('@') 77 | return (user_part,) 78 | 79 | return tuple() 80 | -------------------------------------------------------------------------------- /afew/filters/SpamFilter.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | from afew.filters.HeaderMatchingFilter import HeaderMatchingFilter 5 | 6 | 7 | class SpamFilter(HeaderMatchingFilter): 8 | message = 'Tagging spam messages' 9 | header = 'X-Spam-Flag' 10 | pattern = 'YES' 11 | 12 | def __init__(self, database, tags='+spam', spam_tag=None, **kwargs): 13 | if spam_tag is not None: 14 | # this is for backward-compatibility 15 | tags = '+' + spam_tag 16 | kwargs['tags'] = [tags] 17 | super().__init__(database, **kwargs) 18 | -------------------------------------------------------------------------------- /afew/filters/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import os 5 | import glob 6 | 7 | __all__ = list(filename[:-3] 8 | for filename in glob.glob1(os.path.dirname(__file__), '*.py') 9 | if filename != '__init__.py') 10 | -------------------------------------------------------------------------------- /afew/main.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import sys 5 | 6 | from afew.MailMover import MailMover 7 | 8 | try: 9 | from .files import watch_for_new_files, quick_find_dirs_hack 10 | except ImportError: 11 | watch_available = False 12 | else: 13 | watch_available = True 14 | 15 | 16 | def main(options, database, query_string): 17 | if options.tag: 18 | for filter_ in options.enable_filters: 19 | filter_.run(query_string) 20 | filter_.commit(options.dry_run) 21 | elif options.watch: 22 | if not watch_available: 23 | sys.exit('Sorry, this feature requires Linux and pyinotify') 24 | watch_for_new_files(options, database, 25 | quick_find_dirs_hack(database.db_path)) 26 | elif options.move_mails: 27 | for maildir, rules in options.mail_move_rules.items(): 28 | mover = MailMover(options.mail_move_age, options.mail_move_rename, options.dry_run, options.notmuch_args) 29 | mover.move(maildir, rules) 30 | mover.close() 31 | else: 32 | sys.exit('Weird... please file a bug containing your command line.') 33 | -------------------------------------------------------------------------------- /afew/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) 2013 Patrick Gerken 3 | -------------------------------------------------------------------------------- /afew/tests/test_dkimvalidityfilter.py: -------------------------------------------------------------------------------- 1 | """Test suite for DKIMValidityFilter. 2 | """ 3 | import unittest 4 | from email.utils import make_msgid 5 | from unittest import mock 6 | 7 | import dkim 8 | import dns.exception 9 | 10 | from afew.Database import Database 11 | from afew.filters.DKIMValidityFilter import DKIMValidityFilter 12 | 13 | 14 | class _AddTags: # pylint: disable=too-few-public-methods 15 | """Mock for `add_tags` method of base filter. We need to easily collect 16 | tags added by filter for test assertion. 17 | """ 18 | def __init__(self, tags): 19 | self._tags = tags 20 | 21 | def __call__(self, message, *tags): 22 | self._tags.update(tags) 23 | 24 | 25 | def _make_dkim_validity_filter(): 26 | """Make `DKIMValidityFilter` with mocked `DKIMValidityFilter.add_tags` 27 | method, so in tests we can easily check what tags were added by filter 28 | without fiddling with db. 29 | """ 30 | tags = set() 31 | add_tags = _AddTags(tags) 32 | dkim_filter = DKIMValidityFilter(Database()) 33 | dkim_filter.add_tags = add_tags 34 | return dkim_filter, tags 35 | 36 | 37 | def _make_message(): 38 | """Make mock email Message. 39 | 40 | Mocked methods: 41 | 42 | - `header()` returns non-empty string. When testing with mocked 43 | function for verifying DKIM signature, DKIM signature doesn't matter as 44 | long as it's non-empty string. 45 | 46 | - `filenames()` returns list of non-empty string. When testing with 47 | mocked file open, it must just be non-empty string. 48 | 49 | - `messageid` returns some generated message ID. 50 | """ 51 | message = mock.Mock() 52 | message.header.return_value = 'sig' 53 | message.filenames.return_value = ['a'] 54 | message.messageid = make_msgid() 55 | return message 56 | 57 | 58 | class TestDKIMValidityFilter(unittest.TestCase): 59 | """Test suite for `DKIMValidityFilter`. 60 | """ 61 | @mock.patch('afew.filters.DKIMValidityFilter.open', 62 | mock.mock_open(read_data=b'')) 63 | def test_no_dkim_header(self): 64 | """Test message without DKIM-Signature header doesn't get any tags. 65 | """ 66 | dkim_filter, tags = _make_dkim_validity_filter() 67 | message = _make_message() 68 | message.header.return_value = False 69 | 70 | with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ 71 | as dkim_verify: 72 | dkim_verify.return_value = True 73 | dkim_filter.handle_message(message) 74 | 75 | self.assertSetEqual(tags, set()) 76 | 77 | @mock.patch('afew.filters.DKIMValidityFilter.open', 78 | mock.mock_open(read_data=b'')) 79 | def test_dkim_all_ok(self): 80 | """Test message, with multiple files all having good signature, gets 81 | only 'dkim-ok' tag. 82 | """ 83 | dkim_filter, tags = _make_dkim_validity_filter() 84 | message = _make_message() 85 | message.filenames.return_value = ['a', 'b', 'c'] 86 | 87 | with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ 88 | as dkim_verify: 89 | dkim_verify.return_value = True 90 | dkim_filter.handle_message(message) 91 | 92 | self.assertSetEqual(tags, {'dkim-ok'}) 93 | 94 | @mock.patch('afew.filters.DKIMValidityFilter.open', 95 | mock.mock_open(read_data=b'')) 96 | def test_dkim_all_fail(self): 97 | """Test message, with multiple files all having bad signature, gets 98 | only 'dkim-fail' tag. 99 | """ 100 | dkim_filter, tags = _make_dkim_validity_filter() 101 | message = _make_message() 102 | message.filenames.return_value = ['a', 'b', 'c'] 103 | 104 | with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ 105 | as dkim_verify: 106 | dkim_verify.return_value = False 107 | dkim_filter.handle_message(message) 108 | 109 | self.assertSetEqual(tags, {'dkim-fail'}) 110 | 111 | @mock.patch('afew.filters.DKIMValidityFilter.open', 112 | mock.mock_open(read_data=b'')) 113 | def test_dkim_some_fail(self): 114 | """Test message, with multiple files but only some having bad 115 | signature, still gets only 'dkim-fail' tag. 116 | """ 117 | dkim_filter, tags = _make_dkim_validity_filter() 118 | message = _make_message() 119 | message.filenames.return_value = ['a', 'b', 'c'] 120 | 121 | with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ 122 | as dkim_verify: 123 | dkim_verify.side_effect = [True, False, True] 124 | dkim_filter.handle_message(message) 125 | 126 | self.assertSetEqual(tags, {'dkim-fail'}) 127 | 128 | @mock.patch('afew.filters.DKIMValidityFilter.open', 129 | mock.mock_open(read_data=b'')) 130 | def test_dkim_dns_resolve_failure(self): 131 | """Test message, on which DNS resolution failure happens when verifying 132 | DKIM signature, gets only 'dkim-fail' tag. 133 | """ 134 | dkim_filter, tags = _make_dkim_validity_filter() 135 | message = _make_message() 136 | 137 | with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ 138 | as dkim_verify: 139 | dkim_verify.side_effect = dns.resolver.NoNameservers() 140 | dkim_filter.handle_message(message) 141 | 142 | self.assertSetEqual(tags, {'dkim-fail'}) 143 | 144 | @mock.patch('afew.filters.DKIMValidityFilter.open', 145 | mock.mock_open(read_data=b'')) 146 | def test_dkim_verify_failed(self): 147 | """Test message, on which DKIM key parsing failure occurs, gets only 148 | 'dkim-fail' tag. 149 | """ 150 | dkim_filter, tags = _make_dkim_validity_filter() 151 | message = _make_message() 152 | 153 | with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ 154 | as dkim_verify: 155 | dkim_verify.side_effect = dkim.KeyFormatError() 156 | dkim_filter.handle_message(message) 157 | 158 | self.assertSetEqual(tags, {'dkim-fail'}) 159 | -------------------------------------------------------------------------------- /afew/tests/test_headermatchingfilter.py: -------------------------------------------------------------------------------- 1 | """Test suite for DKIMValidityFilter. 2 | """ 3 | import unittest 4 | from email.utils import make_msgid 5 | from unittest import mock 6 | 7 | from afew.Database import Database 8 | from afew.filters.HeaderMatchingFilter import HeaderMatchingFilter 9 | 10 | from notmuch2._errors import NullPointerError 11 | 12 | 13 | class _AddTags: # pylint: disable=too-few-public-methods 14 | """Mock for `add_tags` method of base filter. We need to easily collect 15 | tags added by filter for test assertion. 16 | """ 17 | def __init__(self, tags): 18 | self._tags = tags 19 | 20 | def __call__(self, message, *tags): 21 | self._tags.update(tags) 22 | 23 | 24 | def _make_header_matching_filter(): 25 | """Make `HeaderMatchingFilter` with mocked `HeaderMatchingFilter.add_tags` 26 | method, so in tests we can easily check what tags were added by filter 27 | without fiddling with db. 28 | """ 29 | tags = set() 30 | add_tags = _AddTags(tags) 31 | header_filter = HeaderMatchingFilter(Database(), header="X-test", pattern="") 32 | header_filter.add_tags = add_tags 33 | return header_filter, tags 34 | 35 | 36 | def _make_message(should_fail): 37 | """Make mock email Message. 38 | 39 | Mocked methods: 40 | 41 | - `header()` returns non-empty string. When testing with mocked 42 | function for verifying DKIM signature, DKIM signature doesn't matter as 43 | long as it's non-empty string. 44 | 45 | - `filenames()` returns list of non-empty string. When testing with 46 | mocked file open, it must just be non-empty string. 47 | 48 | - `message` returns some generated message ID. 49 | """ 50 | message = mock.Mock() 51 | if should_fail: 52 | message.header.side_effect = NullPointerError 53 | else: 54 | message.header.return_value = 'header' 55 | message.filenames.return_value = ['a'] 56 | message.tags = ['a'] 57 | message.messageid = make_msgid() 58 | return message 59 | 60 | 61 | class TestHeaderMatchingFilter(unittest.TestCase): 62 | """Test suite for `HeaderMatchingFilter`. 63 | """ 64 | @mock.patch('afew.filters.HeaderMatchingFilter.open', 65 | mock.mock_open(read_data=b'')) 66 | def test_header_exists(self): 67 | """Test message with header that exists. 68 | """ 69 | header_filter, tags = _make_header_matching_filter() 70 | message = _make_message(False) 71 | header_filter.handle_message(message) 72 | 73 | self.assertSetEqual(tags, set()) 74 | 75 | @mock.patch('afew.filters.HeaderMatchingFilter.open', 76 | mock.mock_open(read_data=b'')) 77 | def test_header_doesnt_exist(self): 78 | """Test message with header that exists. 79 | """ 80 | header_filter, tags = _make_header_matching_filter() 81 | message = _make_message(True) 82 | header_filter.handle_message(message) 83 | 84 | self.assertSetEqual(tags, set()) 85 | -------------------------------------------------------------------------------- /afew/tests/test_mailmover.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | 3 | import email.message 4 | from email.utils import make_msgid 5 | from freezegun import freeze_time 6 | import mailbox 7 | import os 8 | import shutil 9 | import tempfile 10 | import unittest 11 | import notmuch2 12 | 13 | from afew.Database import Database 14 | from afew.NotmuchSettings import notmuch_settings, write_notmuch_settings 15 | 16 | 17 | def create_mail(msg, maildir, notmuch_db, tags, old=False): 18 | email_message = email.message.EmailMessage() 19 | # freezegun doesn't handle time zones properly when generating UNIX 20 | # timestamps. When the local timezone is UTC+2, the generated timestamp 21 | # is 2 hours ahead of what it should be. Due to this we need to make sure 22 | # that the dates are always sufficiently far behind 2019-01-30 12:00 to 23 | # handle up to UTC+12 . 24 | if old: 25 | email_message['Date'] = 'Wed, 10 Jan 2019 13:00:00 +0100' 26 | else: 27 | email_message['Date'] = 'Wed, 20 Jan 2019 13:00:00 +0100' 28 | email_message['From'] = 'You ' 29 | email_message['To'] = 'Me ' 30 | email_message['Message-ID'] = make_msgid() 31 | email_message.set_content(msg) 32 | 33 | maildir_message = mailbox.MaildirMessage(email_message) 34 | message_key = maildir.add(maildir_message) 35 | 36 | fname = os.path.join(maildir._path, maildir._lookup(message_key)) 37 | notmuch_msg = notmuch_db.add_message(fname) 38 | for tag in tags: 39 | notmuch_msg.tags.add(tag) 40 | 41 | # Remove the angle brackets automatically added around the message ID by make_msgid. 42 | stripped_msgid = email_message['Message-ID'].strip('<>') 43 | return (stripped_msgid, msg) 44 | 45 | 46 | @freeze_time("2019-01-30 12:00:00") 47 | class TestMailMover(unittest.TestCase): 48 | def setUp(self): 49 | self.test_dir = tempfile.mkdtemp() 50 | 51 | os.environ['MAILDIR'] = self.test_dir 52 | os.environ['NOTMUCH_CONFIG'] = os.path.join(self.test_dir, 'notmuch-config') 53 | 54 | notmuch_settings['database'] = {'path': self.test_dir} 55 | notmuch_settings['new'] = {'tags': 'new'} 56 | write_notmuch_settings() 57 | 58 | # Create notmuch database 59 | notmuch2.Database.create().close() 60 | 61 | self.root = mailbox.Maildir(self.test_dir) 62 | self.inbox = self.root.add_folder('inbox') 63 | self.archive = self.root.add_folder('archive') 64 | self.spam = self.root.add_folder('spam') 65 | 66 | # Dict of rules that are passed to MailMover. 67 | # 68 | # The top level key represents a particular mail directory to work on. 69 | # 70 | # The second level key is the notmuch query that MailMover will execute, 71 | # and its value is the directory to move the matching emails to. 72 | self.rules = { 73 | '.inbox': { 74 | 'tag:archive AND NOT tag:spam': '.archive', 75 | 'tag:spam': '.spam', 76 | }, 77 | '.archive': { 78 | 'NOT tag:archive AND NOT tag:spam': '.inbox', 79 | 'tag:spam': '.spam', 80 | }, 81 | '.spam': { 82 | 'NOT tag:spam AND tag:archive': '.archive', 83 | 'NOT tag:spam AND NOT tag:archive': '.inbox', 84 | }, 85 | } 86 | 87 | def tearDown(self): 88 | shutil.rmtree(self.test_dir) 89 | 90 | @staticmethod 91 | def get_folder_content(db, folder): 92 | ret = set() 93 | for msg in db.open().messages('folder:{}'.format(folder)): 94 | with open(msg.path) as f: 95 | ret.add((os.path.basename(msg.messageid), 96 | email.message_from_file(f).get_payload())) 97 | return ret 98 | 99 | def test_all_rule_cases(self): 100 | from afew import MailMover 101 | 102 | with Database() as db: 103 | expect_inbox = set([ 104 | create_mail('In inbox, untagged\n', self.inbox, db, []), 105 | create_mail('In archive, untagged\n', self.archive, db, []), 106 | create_mail('In spam, untagged\n', self.spam, db, []), 107 | ]) 108 | 109 | expect_archive = set([ 110 | create_mail('In inbox, tagged archive\n', self.inbox, db, ['archive']), 111 | create_mail('In archive, tagged archive\n', self.archive, db, ['archive']), 112 | create_mail('In spam, tagged archive\n', self.spam, db, ['archive']), 113 | ]) 114 | 115 | expect_spam = set([ 116 | create_mail('In inbox, tagged spam\n', self.inbox, db, ['spam']), 117 | create_mail('In inbox, tagged archive, spam\n', self.inbox, db, ['archive', 'spam']), 118 | create_mail('In archive, tagged spam\n', self.archive, db, ['spam']), 119 | create_mail('In archive, tagged archive, spam\n', self.archive, db, ['archive', 'spam']), 120 | create_mail('In spam, tagged spam\n', self.spam, db, ['spam']), 121 | create_mail('In spam, tagged archive, spam\n', self.spam, db, ['archive', 'spam']), 122 | ]) 123 | 124 | mover = MailMover.MailMover(quiet=True) 125 | mover.move('.inbox', self.rules['.inbox']) 126 | mover.move('.archive', self.rules['.archive']) 127 | mover.move('.spam', self.rules['.spam']) 128 | mover.close() 129 | 130 | with Database() as db: 131 | self.assertEqual(expect_inbox, self.get_folder_content(db, '.inbox')) 132 | self.assertEqual(expect_archive, self.get_folder_content(db, '.archive')) 133 | self.assertEqual(expect_spam, self.get_folder_content(db, '.spam')) 134 | 135 | def test_max_age(self): 136 | from afew import MailMover 137 | 138 | with Database() as db: 139 | expect_inbox = set([ 140 | create_mail('In inbox, tagged archive, old\n', self.inbox, db, ['archive'], old=True), 141 | ]) 142 | 143 | expect_archive = set([ 144 | create_mail('In inbox, tagged archive\n', self.inbox, db, ['archive']), 145 | ]) 146 | 147 | expect_spam = set([]) 148 | 149 | mover = MailMover.MailMover(max_age=15, quiet=True) 150 | mover.move('.inbox', self.rules['.inbox']) 151 | mover.move('.archive', self.rules['.archive']) 152 | mover.move('.spam', self.rules['.spam']) 153 | mover.close() 154 | 155 | with Database() as db: 156 | self.assertEqual(expect_inbox, self.get_folder_content(db, '.inbox')) 157 | self.assertEqual(expect_archive, self.get_folder_content(db, '.archive')) 158 | self.assertEqual(expect_spam, self.get_folder_content(db, '.spam')) 159 | -------------------------------------------------------------------------------- /afew/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) 2013 Patrick Gerken 3 | 4 | import unittest 5 | 6 | 7 | class TestFilterRegistry(unittest.TestCase): 8 | 9 | def test_all_filters_exist(self): 10 | from afew import FilterRegistry 11 | self.assertTrue(hasattr(FilterRegistry.all_filters, 'get')) 12 | 13 | def test_entry_point_registration(self): 14 | from afew import FilterRegistry 15 | 16 | class FakeRegistry: 17 | name = 'test' 18 | 19 | def load(self): 20 | return 'class' 21 | 22 | registry = FilterRegistry.FilterRegistry([FakeRegistry()]) 23 | 24 | self.assertEqual('class', registry['test']) 25 | 26 | def test_add_FilterRegistry(self): 27 | from afew import FilterRegistry 28 | try: 29 | FilterRegistry.all_filters['test'] = 'class' 30 | self.assertEqual('class', FilterRegistry.all_filters['test']) 31 | finally: 32 | del FilterRegistry.all_filters['test'] 33 | -------------------------------------------------------------------------------- /afew/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import re 5 | from datetime import datetime 6 | 7 | 8 | def get_message_summary(message): 9 | when = datetime.fromtimestamp(float(message.date)) 10 | sender = get_sender(message) 11 | try: 12 | subject = message.header('Subject') 13 | except LookupError: 14 | subject = '' 15 | return '[{date}] {sender} | {subject}'.format(date=when, sender=sender, 16 | subject=subject) 17 | 18 | 19 | def get_sender(message): 20 | try: 21 | sender = message.header('From') 22 | except LookupError: 23 | sender = '' 24 | name_match = re.search(r'(.+) <.+@.+\..+>', sender) 25 | if name_match: 26 | sender = name_match.group(1) 27 | return sender 28 | -------------------------------------------------------------------------------- /docs/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afewmail/afew/bc09b1481d8b7e10f5bd90bae663664107e8552d/docs/_static/.keep -------------------------------------------------------------------------------- /docs/commandline.rst: -------------------------------------------------------------------------------- 1 | Command Line Usage 2 | ================== 3 | 4 | Ultimately afew is a command line tool. You have to specify an action, and 5 | whether to act on all messages, or only on new messages. The actions you can 6 | choose from are: 7 | 8 | tag 9 | run the tag filters. See `Initial tagging`_. 10 | 11 | watch 12 | continuously monitor the mailbox for new files 13 | 14 | move-mails 15 | move mail files between maildir folders 16 | 17 | Initial tagging 18 | --------------- 19 | 20 | Basic tagging stuff requires no configuration, just run 21 | 22 | .. code-block:: sh 23 | 24 | $ afew --tag --new 25 | # or to tag *all* messages 26 | $ afew --tag --all 27 | 28 | To do this automatically you can add the following hook into your 29 | `~/.offlineimaprc`: 30 | 31 | .. code-block:: ini 32 | 33 | postsynchook = ionice -c 3 chrt --idle 0 /bin/sh -c "notmuch new && afew --tag --new" 34 | 35 | There is a lot more to say about general filter :doc:`configuration` 36 | and the different :doc:`filters` provided by afew. 37 | 38 | Simulation 39 | ^^^^^^^^^^ 40 | 41 | Adding `--dry-run` to any `--tag` or `--sync-tags` action prevents 42 | modification of the notmuch db. Add some `-vv` goodness to see some 43 | action. 44 | 45 | Move Mode 46 | --------- 47 | 48 | To invoke afew in move mode, provide the `--move-mails` option on the 49 | command line. Move mode will respect `--dry-run`, so throw in 50 | `--verbose` and watch what effects a real run would have. 51 | 52 | In move mode, afew will check all mails (or only recent ones) in the 53 | configured maildir folders, deciding whether they should be moved to 54 | another folder. 55 | 56 | The decision is based on rules defined in your config file. A rule is 57 | bound to a source folder and specifies a target folder into which a 58 | mail will be moved that is matched by an associated query. 59 | 60 | This way you will be able to transfer your sorting principles roughly 61 | to the classic folder based maildir structure understood by your 62 | traditional mail server. Tag your mails with notmuch, call afew 63 | `--move-mails` in an offlineimap presynchook and enjoy a clean inbox 64 | in your webinterface/GUI-client at work. 65 | 66 | Note that in move mode, afew calls `notmuch new` after moving mails around. 67 | You can use `afew -m --notmuch-args=--no-hooks` in a pre-new notmuch hook 68 | to avoid loops. 69 | 70 | For information on how to configure rules for move mode, what you can 71 | do with it and what you can't, please refer to :doc:`move_mode`. 72 | 73 | Commandline help 74 | ---------------- 75 | 76 | The full set of options is: 77 | 78 | .. code-block:: sh 79 | 80 | $ afew --help 81 | Usage: afew [options] [--] [query] 82 | 83 | Options: 84 | -h, --help show this help message and exit 85 | 86 | Actions: 87 | Please specify exactly one action. 88 | 89 | -t, --tag run the tag filters 90 | -w, --watch continuously monitor the mailbox for new files 91 | -m, --move-mails move mail files between maildir folders 92 | 93 | Query modifiers: 94 | Please specify either --all or --new or a query string. 95 | 96 | -a, --all operate on all messages 97 | -n, --new operate on all new messages 98 | 99 | General options: 100 | -C NOTMUCH_CONFIG, --notmuch-config=NOTMUCH_CONFIG 101 | path to the notmuch configuration file [default: 102 | $NOTMUCH_CONFIG or ~/.notmuch-config] 103 | -e ENABLE_FILTERS, --enable-filters=ENABLE_FILTERS 104 | filter classes to use, separated by ',' [default: 105 | filters specified in afew's config] 106 | -d, --dry-run don't change the db [default: False] 107 | -R REFERENCE_SET_SIZE, --reference-set-size=REFERENCE_SET_SIZE 108 | size of the reference set [default: 1000] 109 | -T DAYS, --reference-set-timeframe=DAYS 110 | do not use mails older than DAYS days [default: 30] 111 | -v, --verbose be more verbose, can be given multiple times 112 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) 2011 Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | # afew documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Dec 23 21:19:37 2011. 6 | # 7 | # This file is execfile()d with the current directory set to its containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | from pkg_resources import get_distribution 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('../..')) 23 | 24 | # Create mocks so we don't depend on non standard modules to build the 25 | # documentation 26 | 27 | class Mock: 28 | def __init__(self, *args, **kwargs): 29 | pass 30 | 31 | def __getattr__(self, name): 32 | return Mock if name != '__file__' else '/dev/null' 33 | 34 | MOCK_MODULES = [ 35 | 'notmuch', 36 | 'notmuch.globals', 37 | 'argparse', 38 | ] 39 | 40 | for mod_name in MOCK_MODULES: 41 | sys.modules[mod_name] = Mock() 42 | 43 | # -- General configuration ----------------------------------------------------- 44 | 45 | # If your documentation needs a minimal Sphinx version, state it here. 46 | # needs_sphinx = '1.0' 47 | 48 | # Add any Sphinx extension module names here, as strings. They can be extensions 49 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 50 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] 51 | 52 | # Add any paths that contain templates here, relative to this directory. 53 | templates_path = ['_templates'] 54 | 55 | # The suffix of source filenames. 56 | source_suffix = '.rst' 57 | 58 | # The encoding of source files. 59 | source_encoding = 'utf-8-sig' 60 | 61 | # The master toctree document. 62 | master_doc = 'index' 63 | 64 | # General information about the project. 65 | project = u'afew' 66 | copyright = u'afewmail project' 67 | 68 | # The version info for the project you're documenting, acts as replacement for 69 | # |version| and |release|, also used in various other places throughout the 70 | # built documents. 71 | # 72 | # The full version, including alpha/beta/rc tags. 73 | pretended_version = os.environ.get('SETUPTOOLS_SCM_PRETEND_VERSION') 74 | if pretended_version: 75 | release = pretended_version 76 | else: 77 | release = get_distribution('afew').version 78 | # The X.Y.Z version. 79 | version = '.'.join(release.split('.')[:3]) 80 | 81 | # The language for content autogenerated by Sphinx. Refer to documentation 82 | # for a list of supported languages. 83 | # language = None 84 | 85 | # There are two options for replacing |today|: either, you set today to some 86 | # non-false value, then it is used: 87 | # today = '' 88 | # Else, today_fmt is used as the format for a strftime call. 89 | # today_fmt = '%B %d, %Y' 90 | 91 | # List of patterns, relative to source directory, that match files and 92 | # directories to ignore when looking for source files. 93 | exclude_patterns = [] 94 | 95 | # The reST default role (used for this markup: `text`) to use for all documents. 96 | # default_role = None 97 | 98 | # If true, '()' will be appended to :func: etc. cross-reference text. 99 | # add_function_parentheses = True 100 | 101 | # If true, the current module name will be prepended to all description 102 | # unit titles (such as .. function::). 103 | # add_module_names = True 104 | 105 | # If true, sectionauthor and moduleauthor directives will be shown in the 106 | # output. They are ignored by default. 107 | # show_authors = False 108 | 109 | # The name of the Pygments (syntax highlighting) style to use. 110 | pygments_style = 'sphinx' 111 | 112 | # A list of ignored prefixes for module index sorting. 113 | # modindex_common_prefix = [] 114 | 115 | 116 | # -- Options for HTML output --------------------------------------------------- 117 | 118 | # The theme to use for HTML and HTML Help pages. See the documentation for 119 | # a list of builtin themes. 120 | html_theme = 'default' 121 | 122 | # Theme options are theme-specific and customize the look and feel of a theme 123 | # further. For a list of options available for each theme, see the 124 | # documentation. 125 | # html_theme_options = {} 126 | 127 | # Add any paths that contain custom themes here, relative to this directory. 128 | # html_theme_path = [] 129 | 130 | # The name for this set of Sphinx documents. If None, it defaults to 131 | # " v documentation". 132 | # html_title = None 133 | 134 | # A shorter title for the navigation bar. Default is the same as html_title. 135 | # html_short_title = None 136 | 137 | # The name of an image file (relative to this directory) to place at the top 138 | # of the sidebar. 139 | # html_logo = None 140 | 141 | # The name of an image file (within the static path) to use as favicon of the 142 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 143 | # pixels large. 144 | # html_favicon = None 145 | 146 | # Add any paths that contain custom static files (such as style sheets) here, 147 | # relative to this directory. They are copied after the builtin static files, 148 | # so a file named "default.css" will overwrite the builtin "default.css". 149 | html_static_path = ['_static'] 150 | 151 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 152 | # using the given strftime format. 153 | # html_last_updated_fmt = '%b %d, %Y' 154 | 155 | # If true, SmartyPants will be used to convert quotes and dashes to 156 | # typographically correct entities. 157 | # html_use_smartypants = True 158 | 159 | # Custom sidebar templates, maps document names to template names. 160 | # html_sidebars = {} 161 | 162 | # Additional templates that should be rendered to pages, maps page names to 163 | # template names. 164 | # html_additional_pages = {} 165 | 166 | # If false, no module index is generated. 167 | # html_domain_indices = True 168 | 169 | # If false, no index is generated. 170 | # html_use_index = True 171 | 172 | # If true, the index is split into individual pages for each letter. 173 | # html_split_index = False 174 | 175 | # If true, links to the reST sources are added to the pages. 176 | # html_show_sourcelink = True 177 | 178 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 179 | # html_show_sphinx = True 180 | 181 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 182 | # html_show_copyright = True 183 | 184 | # If true, an OpenSearch description file will be output, and all pages will 185 | # contain a tag referring to it. The value of this option must be the 186 | # base URL from which the finished HTML is served. 187 | # html_use_opensearch = '' 188 | 189 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 190 | # html_file_suffix = None 191 | 192 | # Output file base name for HTML help builder. 193 | htmlhelp_basename = 'afewdoc' 194 | 195 | 196 | # -- Options for LaTeX output -------------------------------------------------- 197 | 198 | # The paper size ('letter' or 'a4'). 199 | # latex_paper_size = 'letter' 200 | 201 | # The font size ('10pt', '11pt' or '12pt'). 202 | # latex_font_size = '10pt' 203 | 204 | # Grouping the document tree into LaTeX files. List of tuples 205 | # (source start file, target name, title, author, documentclass [howto/manual]). 206 | latex_documents = [ 207 | ('index', 'afew.tex', u'afew Documentation', 208 | u'Justus Winter', 'manual'), 209 | ] 210 | 211 | # The name of an image file (relative to this directory) to place at the top of 212 | # the title page. 213 | # latex_logo = None 214 | 215 | # For "manual" documents, if this is true, then toplevel headings are parts, 216 | # not chapters. 217 | # latex_use_parts = False 218 | 219 | # If true, show page references after internal links. 220 | # latex_show_pagerefs = False 221 | 222 | # If true, show URL addresses after external links. 223 | # latex_show_urls = False 224 | 225 | # Additional stuff for the LaTeX preamble. 226 | # latex_preamble = '' 227 | 228 | # Documents to append as an appendix to all manuals. 229 | # latex_appendices = [] 230 | 231 | # If false, no module index is generated. 232 | # latex_domain_indices = True 233 | 234 | 235 | # -- Options for manual page output -------------------------------------------- 236 | 237 | # One entry per manual page. List of tuples 238 | # (source start file, name, description, authors, manual section). 239 | man_pages = [ 240 | ('index', 'afew', u'afew Documentation', 241 | [u'Justus Winter'], 1) 242 | ] 243 | 244 | 245 | # Example configuration for intersphinx: refer to the Python standard library. 246 | intersphinx_mapping = { 247 | 'python': ('https://docs.python.org/', None), 248 | 'notmuch': ('https://notmuch.readthedocs.io/en/latest/', None), 249 | 'alot': ('https://alot.readthedocs.io/en/latest/', None), 250 | } 251 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | Configuration File 5 | ------------------ 6 | 7 | Customization of tag filters takes place in afew's config file in 8 | `~/.config/afew/config`. 9 | 10 | NotMuch Config 11 | -------------- 12 | 13 | afew tries to adapt to the new tag that notmuch sets on new email, but has 14 | mostly been developed and used against the **new** tag. To use that, 15 | make sure that `~/.notmuch-config` contains: 16 | 17 | .. code-block:: ini 18 | 19 | [new] 20 | tags=new 21 | 22 | afew reads the notmuch database location from notmuch config. When no database 23 | path is set in notmuch config, afew uses the `MAILDIR` environment variable 24 | when set, or `$HOME/mail` as a fallback, like notmuch CLI does. If a relative 25 | path is provided, afew prepends `$HOME/` to the path in the same manner as 26 | notmuch, which was introduced in version 0.28 of notmuch. 27 | 28 | Filter Configuration 29 | -------------------- 30 | 31 | You can modify filters, and define your own versions of the base Filter that 32 | allow you to tag messages in a similar way to the `notmuch tag` command, using 33 | the config file. The default config file is: 34 | 35 | .. code-block:: ini 36 | 37 | [SpamFilter] 38 | [KillThreadsFilter] 39 | [ListMailsFilter] 40 | [ArchiveSentMailsFilter] 41 | sent_tag = '' 42 | [InboxFilter] 43 | 44 | See the :doc:`filters` page for the details of those filters and the custom 45 | arguments they accept. 46 | 47 | You can add filters based on the base filter as well. These can be customised 48 | by specifying settings beneath them. The standard settings, which apply to all 49 | filters, are: 50 | 51 | message 52 | text that will be displayed while running this filter if the verbosity is high 53 | enough. 54 | 55 | query 56 | the query to use against the messages, specified in standard notmuch format. 57 | Note that you don't need to specify the **new** tag - afew will add that when 58 | run with the `--new` flag. 59 | 60 | tags 61 | the tags to add or remove for messages that match the query. Tags to add are 62 | preceded by a **+** and tags to remove are preceded by a **-**. Multiple tags 63 | are separated by semicolons. 64 | 65 | tags_blacklist 66 | if the message has one of these tags, don't add `tags` to it. Tags are 67 | separated by semicolons. 68 | 69 | So to add the **deer** tag to any message to or from `antelope@deer.com` you 70 | could do: 71 | 72 | .. code-block:: ini 73 | 74 | [Filter.1] 75 | query = 'antelope@deer.com' 76 | tags = +deer 77 | message = Wild animals ahoy 78 | 79 | You can also (in combination with the InboxFilter) have email skip the Inbox 80 | by removing the new tag before you get to the InboxFilter: 81 | 82 | .. code-block:: ini 83 | 84 | [Filter.2] 85 | query = from'pointyheaded@boss.com' 86 | tags = -new;+boss 87 | message = Message from above 88 | 89 | Full Sample Config 90 | ------------------ 91 | 92 | Showing some sample configs is the easiest way to understand. The 93 | `notmuch initial tagging page`_ shows a sample config: 94 | 95 | .. _notmuch initial tagging page: http://notmuchmail.org/initial_tagging/ 96 | 97 | .. code-block:: sh 98 | 99 | # immediately archive all messages from "me" 100 | notmuch tag -new -- tag:new and from:me@example.com 101 | 102 | # delete all messages from a spammer: 103 | notmuch tag +deleted -- tag:new and from:spam@spam.com 104 | 105 | # tag all message from notmuch mailing list 106 | notmuch tag +notmuch -- tag:new and to:notmuch@notmuchmail.org 107 | 108 | # finally, retag all "new" messages "inbox" and "unread" 109 | notmuch tag +inbox +unread -new -- tag:new 110 | 111 | The (roughly) equivalent set up in afew would be: 112 | 113 | .. code-block:: ini 114 | 115 | [ArchiveSentMailsFilter] 116 | 117 | [Filter.1] 118 | message = Delete all messages from spammer 119 | query = from:spam@spam.com 120 | tags = +deleted;-new 121 | 122 | [Filter.2] 123 | message = Tag all messages from the notmuch mailing list 124 | query = to:notmuch@notmuchmail.org 125 | tags = +notmuch 126 | 127 | [InboxFilter] 128 | 129 | Not that the queries do not generally include `tag:new` because this is implied when afew 130 | is run with the `--new` flag. 131 | 132 | The differences between them is that 133 | 134 | * the ArchiveSentMailsFilter will add tags specified by `sent_tag` option 135 | (default `''` means add no tags. You may want to set it to `sent`), as well as 136 | archiving the email. And it will not archive email that has been sent to one 137 | of your own addresses. 138 | * the InboxFilter does not add the **unread** tag. But most mail clients will 139 | manage the unread status directly in maildir. 140 | 141 | More Filter Examples 142 | -------------------- 143 | 144 | Here are a few more example filters from github dotfiles: 145 | 146 | .. code-block:: ini 147 | 148 | [Filter.1] 149 | query = 'sicsa-students@sicsa.ac.uk' 150 | tags = +sicsa 151 | message = sicsa 152 | 153 | [Filter.2] 154 | query = 'from:foosoc.ed@gmail.com OR from:GT Silber OR from:lizzie.brough@eusa.ed.ac.uk' 155 | tags = +soc;+foo 156 | message = foosoc 157 | 158 | [Filter.3] 159 | query = 'folder:gmail/G+' 160 | tags = +G+ 161 | message = gmail spam 162 | 163 | # skip inbox 164 | [Filter.6] 165 | query = 'to:notmuch@notmuchmail.org AND (subject:emacs OR subject:elisp OR "(defun" OR "(setq" OR PATCH)' 166 | tags = -new 167 | message = notmuch emacs stuff 168 | 169 | # Assuming the following workflow: all messages for projects or releases should be tagged 170 | # as "project/A", "project/B" respectively "release/1.0.1" or "release/1.2.0". 171 | # 172 | # In most cases replies to messages retain their context: the project, the release(s), .. 173 | # 174 | # The following config will propagate all project/... or release/... tags from a thread 175 | # to all new messages. 176 | 177 | [PropagateTagsByRegexInThreadFilter.1] 178 | propagate_tags = project/.* 179 | # do not tag spam 180 | filter = not is:spam 181 | 182 | [PropagateTagsByRegexInThreadFilter.2] 183 | propagate_tags = release/.* -------------------------------------------------------------------------------- /docs/extending.rst: -------------------------------------------------------------------------------- 1 | Extending afew 2 | ============== 3 | 4 | You can put python files in `~/.config/afew/` and they will be imported by 5 | afew. If you use that python file to define a `Filter` class and use the 6 | `register_filter` decorator then you can refer to it in your filter 7 | configuration. 8 | 9 | So an example small filter you could add might be: 10 | 11 | .. code-block:: python 12 | 13 | from afew.filters.BaseFilter import Filter 14 | from afew.FilterRegistry import register_filter 15 | 16 | PROJECT_MAPPING = { 17 | 'fabric': 'deployment', 18 | 'oldname': 'new-name', 19 | } 20 | 21 | @register_filter 22 | class RedmineFilter(Filter): 23 | message = 'Create tag based on redmine project' 24 | query = 'NOT tag:redmine' 25 | 26 | def handle_message(self, message): 27 | project = message.get_header('X-Redmine-Project') 28 | if project in PROJECT_MAPPING: 29 | project = PROJECT_MAPPING[project] 30 | self.add_tags(message, 'redmine', project) 31 | 32 | We have defined the `message` and `query` class variables that are used 33 | by the parent class `Filter`. The `message` is printed when running with 34 | verbose flags. The `query` is used to select messages to run against - here 35 | we ensure we don't bother looking at messages we've already looked at. 36 | 37 | The `handle_message()` method is the key one to implement. This will be called 38 | for each message that matches the query. The argument is a `notmuch message object`_ 39 | and the key methods used by the afew filters are `header()`, `filename()` 40 | and `get_thread()`. 41 | 42 | .. _notmuch message object: http://pythonhosted.org/notmuch/#message-a-single-message 43 | 44 | Of the methods inherited from the `Filter` class the key ones are `add_tags()` and 45 | `remove_tags()`, but read about the :doc:`implementation` or just read the source 46 | code to get your own ideas. 47 | 48 | Once you've defined your filter, you can add it to your config like any other filter: 49 | 50 | .. code-block:: ini 51 | 52 | [RedmineFilter] 53 | -------------------------------------------------------------------------------- /docs/filters.rst: -------------------------------------------------------------------------------- 1 | Filters 2 | ======= 3 | 4 | The default filter set (if you don't specify anything in the config) is: 5 | 6 | .. code-block:: ini 7 | 8 | [SpamFilter] 9 | [KillThreadsFilter] 10 | [ListMailsFilter] 11 | [ArchiveSentMailsFilter] 12 | [InboxFilter] 13 | 14 | The standard filter :doc:`configuration` can be applied to these filters as 15 | well. Though note that most of the filters below set their own value for 16 | message, query and/or tags, and some ignore some of the standard settings. 17 | 18 | ArchiveSentMailsFilter 19 | ---------------------- 20 | 21 | It extends `SentMailsFilter` with the following feature: 22 | 23 | * Emails filtered by this filter have the **new** tag removed, so will not have 24 | the **inbox** tag added by the InboxFilter. 25 | 26 | DKIMValidityFilter 27 | ------------------ 28 | 29 | This filter verifies DKIM signatures of E-Mails with DKIM header, and adds `dkim-ok` or `dkim-fail` tags. 30 | 31 | DMARCReportInspectionFilter 32 | --------------------------- 33 | 34 | DMARC reports usually come in ZIP files. To check the report you have to 35 | unpack and search thru XML document which is very tedious. This filter tags the 36 | message as follows: 37 | 38 | if there's any SPF failure in any attachment, tag the message with 39 | "dmarc-spf-fail" tag, otherwise tag with "dmarc-spf-ok" 40 | 41 | if there's any DKIM failure in any attachment, tag the message with 42 | "dmarc-dkim-fail" tag, otherwise tag with "dmarc-dkim-ok" 43 | 44 | FolderNameFilter 45 | ---------------- 46 | 47 | For each email, it looks at all folders it is in, and uses the path and filename 48 | as a tag, for the email. So if you have a procmail or sieve set up that puts emails 49 | in folders for you, this might be useful. 50 | 51 | * folder_explicit_list = 52 | 53 | * Tag mails with tag in only. is a space separated 54 | list, not enclosed in quotes or any other way. 55 | * Empty list means all folders (of course blacklist still applies). 56 | * The default is empty list. 57 | * You may use it e.g. to set tags only for specific folders like 'Sent'. 58 | 59 | * folder_blacklist = 60 | 61 | * Never tag mails with tag in . is a space separated 62 | list, not enclosed in quotes or any other way. 63 | * The default is to blacklist no folders. 64 | * You may use it e.g. to avoid mails being tagged as 'INBOX' when there is the more 65 | standard 'inbox' tag. 66 | 67 | * folder_transforms = 68 | 69 | * Transform folder names according to the specified rules before tagging mails. 70 | is a space separated list consisting of 71 | 'folder:tag' style pairs. The colon separates the name of the folder to be 72 | transformed from the tag it is to be transformed into. 73 | * The default is to transform to folder names. 74 | * You may use the rules e.g. to transform the name of your 'Junk' folder into your 75 | 'spam' tag or fix capitalization of your draft and sent folder: 76 | 77 | .. code-block:: ini 78 | 79 | folder_transforms = Junk:spam Drafts:draft Sent:sent 80 | 81 | * folder_lowercases = true 82 | 83 | * Use lowercase tags for all folder names 84 | 85 | * maildir_separator = 86 | 87 | * Use to split your maildir hierarchy into individual tags. 88 | * The default is to split on '.' 89 | * If your maildir hierarchy is represented in the filesystem as collapsed dirs, 90 | is used to split it again before applying tags. If your maildir looks 91 | like this: 92 | 93 | .. code-block:: ini 94 | 95 | [...] 96 | /path/to/maildir/devel.afew/[cur|new|tmp]/... 97 | /path/to/maildir/devel.alot/[cur|new|tmp]/... 98 | /path/to/maildir/devel.notmuch/[cur|new|tmp]/... 99 | [...] 100 | 101 | the mails in your afew folder will be tagged with 'devel' and 'afew'. 102 | 103 | If instead your hierarchy is split by a more conventional '/' or any 104 | other divider 105 | 106 | .. code-block:: ini 107 | 108 | [...] 109 | /path/to/maildir/devel/afew/[cur|new|tmp]/... 110 | /path/to/maildir/devel/alot/[cur|new|tmp]/... 111 | /path/to/maildir/devel/notmuch/[cur|new|tmp]/... 112 | [...] 113 | 114 | you need to configure that divider to have your mails properly tagged: 115 | 116 | .. code-block:: ini 117 | 118 | maildir_separator = / 119 | 120 | HeaderMatchingFilter 121 | -------------------- 122 | 123 | This filter adds tags to a message if the named header matches the regular expression 124 | given. The tags can be set, or based on the match. The settings you can use are: 125 | 126 | * header = 127 | * pattern = 128 | * tags = 129 | 130 | If you surround a tag with `{}` then it will be replaced with the named match. 131 | 132 | Some examples are: 133 | 134 | .. code-block:: ini 135 | 136 | [HeaderMatchingFilter.1] 137 | header = X-Spam-Flag 138 | pattern = YES 139 | tags = +spam 140 | 141 | [HeaderMatchingFilter.2] 142 | header = List-Id 143 | pattern = <(?P.*)> 144 | tags = +lists;+{list_id} 145 | 146 | [HeaderMatchingFilter.3] 147 | header = X-Redmine-Project 148 | pattern = (?P.+) 149 | tags = +redmine;+{project} 150 | 151 | SpamFilter and ListMailsFilter are implemented using HeaderMatchingFilter, and are 152 | only slightly more complicated than the above examples. 153 | 154 | InboxFilter 155 | ----------- 156 | 157 | This removes the **new** tag, and adds the **inbox** tag, to any message that isn't 158 | killed or spam. (The new tags are set in your notmuch config, and default to 159 | just **new**.) 160 | 161 | KillThreadsFilter 162 | ----------------- 163 | 164 | If the new message has been added to a thread that has already been tagged 165 | **killed** then add the **killed** tag to this message. This allows for ignoring 166 | all replies to a particular thread. 167 | 168 | ListMailsFilter 169 | --------------- 170 | 171 | This filter looks for the `List-Id` header, and if it finds it, adds a tag 172 | **lists** and a tag named **lists/**. 173 | 174 | MeFilter 175 | -------- 176 | 177 | Add filter tagging mail sent directly to any of addresses defined in 178 | Notmuch config file: `primary_email` or `other_email`. 179 | Default tag is `to-me` and can be customized with `me_tag` option. 180 | 181 | SentMailsFilter 182 | --------------- 183 | 184 | The settings you can use are: 185 | 186 | * sent_tag = 187 | 188 | * Add to all mails sent from one of your configured mail addresses, *and 189 | not* to any of your addresses. 190 | * The default is to add no tag, so you need to specify something. 191 | * You may e.g. use it to tag all mails sent by you as 'sent'. This may make 192 | special sense in conjunction with a mail client that is able to not only search 193 | for threads but individual mails as well. 194 | 195 | * to_transforms = 196 | 197 | * Transform `To`/`Cc`/`Bcc` e-mail addresses to tags according to the 198 | specified rules. is a space separated list consisting 199 | of 'user_part@domain_part:tags' style pairs. The colon separates the e-mail 200 | address to be transformed from tags it is to be transformed into. ':tags' 201 | is optional and if empty, 'user_part' is used as tag. 'tags' can be 202 | a single tag or semi-colon separated list of tags. 203 | 204 | * It can be used for example to easily tag posts sent to mailing lists which 205 | at this stage don't have `List-Id` field. 206 | 207 | SpamFilter 208 | ---------- 209 | 210 | The settings you can use are: 211 | 212 | * spam_tag = 213 | 214 | * Add to all mails recognized as spam. 215 | * The default is 'spam'. 216 | * You may use it to tag your spam as 'junk', 'scum' or whatever suits your mood. 217 | Note that only a single tag is supported here. 218 | 219 | Email will be considered spam if the header `X-Spam-Flag` is present. 220 | 221 | Customizing filters 222 | ------------------- 223 | 224 | To customize these filters, there are basically two different 225 | possibilities: 226 | 227 | Let's say you like the SpamFilter, but it is way too polite 228 | 229 | 1. Create an filter object and customize it 230 | 231 | .. code-block:: ini 232 | 233 | [SpamFilter.0] # note the index 234 | message = meh 235 | 236 | The index is required if you want to create a new SpamFilter *in 237 | addition to* the default one. If you need just one customized 238 | SpamFilter, you can drop the index and customize the default instance. 239 | 240 | 2. Create a new type... 241 | 242 | .. code-block:: ini 243 | 244 | [ShitFilter(SpamFilter)] 245 | message = I hatez teh spam! 246 | 247 | and create an object or two 248 | 249 | .. code-block:: ini 250 | 251 | [ShitFilter.0] 252 | [ShitFilter.1] 253 | message = Me hatez it too. 254 | 255 | You can provide your own filter implementations too. You have to register 256 | your filters via entry points. See the afew setup.py for examples on how 257 | to register your filters. To add your filters, you just need to install your 258 | package in the context of the afew application. 259 | -------------------------------------------------------------------------------- /docs/implementation.rst: -------------------------------------------------------------------------------- 1 | Implementation 2 | ============== 3 | 4 | Database Manager 5 | ---------------- 6 | 7 | The design of the database manager was inspired by alots database 8 | manager :class:`alot.db.DBManager`. 9 | 10 | .. module:: afew.Database 11 | .. autoclass:: Database 12 | :members: 13 | 14 | Filter 15 | ------ 16 | 17 | .. module:: afew.filters.BaseFilter 18 | .. autoclass:: Filter 19 | :members: 20 | 21 | Configuration management 22 | ------------------------ 23 | 24 | .. automodule:: afew.Settings 25 | :members: 26 | 27 | .. automodule:: afew.NotmuchSettings 28 | :members: 29 | 30 | Miscellanious utility functions 31 | ------------------------------- 32 | 33 | .. currentmodule:: afew.utils 34 | .. automodule:: afew.utils 35 | :members: 36 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to afew's documentation! 2 | ================================ 3 | 4 | `afew` is an initial tagging script for notmuch mail: 5 | 6 | * http://notmuchmail.org/ 7 | * http://notmuchmail.org/initial_tagging/ 8 | 9 | Its basic task is to provide automatic tagging each time new mail is registered 10 | with notmuch. In a classic setup, you might call it after `notmuch new` in an 11 | offlineimap post sync hook or in the notmuch `post-new` hook. 12 | 13 | It can do basic thing such as adding tags based on email headers or maildir 14 | folders, handling killed threads and spam. 15 | 16 | fyi: afew plays nicely with `alot`, a GUI for notmuch mail ;) 17 | 18 | * https://github.com/pazz/alot 19 | 20 | Contents: 21 | 22 | .. toctree:: 23 | :maxdepth: 2 24 | 25 | quickstart 26 | installation 27 | commandline 28 | configuration 29 | filters 30 | move_mode 31 | extending 32 | implementation 33 | 34 | Indices and tables 35 | ================== 36 | 37 | * :ref:`genindex` 38 | * :ref:`modindex` 39 | * :ref:`search` 40 | 41 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Requirements 5 | ------------ 6 | 7 | afew works with python 3.6+, and requires notmuch and its python bindings. 8 | On Debian/Ubuntu systems you can install these by doing: 9 | 10 | .. code-block:: sh 11 | 12 | $ sudo aptitude install notmuch python-notmuch python-dev python-setuptools 13 | 14 | Note: if you are installing `notmuch` using Homebrew on macOS, make sure 15 | to run ``$ brew install --with-python3 notmuch``, because the brew formula 16 | doesn't install python3 notmuch bindings by default. 17 | 18 | Unprivileged Install 19 | -------------------- 20 | 21 | It is recommended to install `afew` itself inside a virtualenv as an unprivileged 22 | user, either via checking out the source code and installing via setup.py, or 23 | via pip. 24 | 25 | .. code:: bash 26 | 27 | # create and activate virtualenv 28 | $ python -m venv --system-site-packages .venv 29 | $ source .venv/bin/activate 30 | 31 | # install via pip from PyPI: 32 | $ pip install afew 33 | 34 | # or install from source: 35 | $ python setup.py install --prefix=~/.local 36 | 37 | 38 | You might want to symlink `.venv/bin/afew` somewhere inside your path 39 | (~/bin/ in this case): 40 | 41 | .. code:: bash 42 | 43 | $ ln -snr .venv/bin/afew ~/.bin/afew 44 | 45 | Building documentation 46 | ---------------------- 47 | 48 | Documentation can be built in various formats using Sphinx: 49 | 50 | .. code:: bash 51 | 52 | # build docs into build/sphinx/{html,man} 53 | $ python setup.py build_sphinx -b html,man 54 | -------------------------------------------------------------------------------- /docs/move_mode.rst: -------------------------------------------------------------------------------- 1 | Move Mode 2 | ========= 3 | 4 | Configuration Section 5 | --------------------- 6 | 7 | Here is a full sample configuration for move mode: 8 | 9 | .. code-block:: ini 10 | 11 | [MailMover] 12 | folders = INBOX Junk 13 | rename = False 14 | max_age = 15 15 | 16 | # rules 17 | INBOX = 'tag:spam':Junk 'NOT tag:inbox':Archive 18 | Junk = 'NOT tag:spam AND tag:inbox':INBOX 'NOT tag:spam':Archive 19 | 20 | Below we explain what each bit of this means. 21 | 22 | Rules 23 | ----- 24 | 25 | First you need to specify which folders should be checked for mails 26 | that are to be moved (as a whitespace separated list). Folder names 27 | containing whitespace need to be quoted: 28 | 29 | .. code-block:: ini 30 | 31 | folders = INBOX Junk "Sent Mail" 32 | 33 | Then you have to specify rules that define move actions of the form 34 | 35 | .. code-block:: ini 36 | 37 | = ['':]+ 38 | 39 | Every mail in the `` folder that matches a `` will be moved into the 40 | `` folder associated with that query. A message that matches 41 | multiple queries will be copied to multiple destinations. 42 | 43 | You can bind as many rules to a maildir folder as you deem necessary. Just add 44 | them as elements of a (whitespace separated) list. 45 | 46 | Please note, though, that you need to specify at least one rule for every folder 47 | given by the `folders` option and at least one folder to check in order to use 48 | the move mode. 49 | 50 | .. code-block:: ini 51 | 52 | INBOX = 'tag:spam':Junk 53 | 54 | will bind one rule to the maildir folder `INBOX` that states that all mails in 55 | said folder that carry (potentially among others) the tag **spam** are to be moved 56 | into the folder `Junk`. 57 | 58 | With `` being an arbitrary notmuch query, you have the power to construct 59 | arbitrarily flexible rules. You can check for the absence of tags and look out 60 | for combinations of attributes: 61 | 62 | .. code-block:: ini 63 | 64 | Junk = 'NOT tag:spam AND tag:inbox':INBOX 'NOT tag:spam':Archive 65 | 66 | The above rules will move all mails in `Junk` that don't have the **spam** tag 67 | but do have an **inbox** tag into the directory `INBOX`. All other mails not 68 | tagged with **spam** will be moved into `Archive`. 69 | 70 | Max Age 71 | ------- 72 | 73 | You can limit the age of mails you want to move by setting the `max_age` option 74 | in the configuration section. By providing 75 | 76 | .. code-block:: ini 77 | 78 | max_age = 15 79 | 80 | afew will only check mails at most 15 days old. 81 | 82 | Rename 83 | ------ 84 | 85 | Set this option if you are using the `mbsync` IMAP syncing tool. 86 | `mbsync` adds a unique identifier to files' names when it syncs them. 87 | If the `rename` option is not set, moving files can cause UID conflicts 88 | and prevent `mbsync` from syncing with error messages such as 89 | "Maildir error: duplicate UID 1234" or "UID 567 is beyond highest assigned UID 89". 90 | 91 | When the option is set, afew will rename files while moving them, 92 | removing the UID but preserving other `mbsync` information. 93 | This allows `mbsync` to assign a new UID to the file and avoid UID conflicts. 94 | 95 | If you are using `offlineimap`, you can safely ignore this option. 96 | 97 | .. code-block:: ini 98 | 99 | rename = True 100 | 101 | 102 | Limitations 103 | ----------- 104 | 105 | **(1)** Rules don't manipulate tags. 106 | 107 | .. code-block:: ini 108 | 109 | INBOX = 'NOT tag:inbox':Archive 110 | Junk = 'NOT tag:spam':INBOX 111 | 112 | The above combination of rules might prove tricky, since you might expect 113 | de-spammed mails to end up in `INBOX`. But since the `Junk` rule will *not* add 114 | an **inbox** tag, the next run in move mode might very well move the matching 115 | mails into `Archive`. 116 | 117 | Then again, if you remove the **spam** tag and do not set an **inbox** tag, how 118 | would you come to expect the mail would end up in your INBOX folder after 119 | moving it? ;) 120 | 121 | **(2)** There is no 1:1 mapping between folders and tags. And that's a 122 | feature. If you tag a mail with two tags and there is a rule for each 123 | of them, both rules will apply. Your mail will be copied into two 124 | destination folders, then removed from its original location. 125 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quick Start 2 | =========== 3 | 4 | The steps to get up and running are: 5 | 6 | * install the afew package 7 | * create the config files 8 | * add a notmuch post-new hook that calls afew 9 | 10 | Install 11 | ------- 12 | 13 | The following commands will get you going on Debian/Ubuntu systems: 14 | 15 | .. code-block:: sh 16 | 17 | $ sudo aptitude install notmuch python-notmuch dbacl 18 | $ git clone git://github.com/teythoon/afew.git 19 | $ cd afew 20 | $ python setup.py install --prefix=~/.local 21 | 22 | Ensure that `~/.local/bin` is in your path. One way is to add the following to 23 | your `~/.bashrc`: 24 | 25 | .. code-block:: sh 26 | 27 | if [ -d ~/.local/bin ]; then 28 | PATH=$PATH:~/.local/bin 29 | fi 30 | 31 | See :doc:`installation` for a more detailed guide. 32 | 33 | Initial Config 34 | -------------- 35 | 36 | Make sure that `~/.notmuch-config` reads: 37 | 38 | .. code-block:: ini 39 | 40 | [new] 41 | tags=new 42 | 43 | Put a list of filters into `~/.config/afew/config`: 44 | 45 | .. code-block:: ini 46 | 47 | # This is the default filter chain 48 | [SpamFilter] 49 | [KillThreadsFilter] 50 | [ListMailsFilter] 51 | [ArchiveSentMailsFilter] 52 | [InboxFilter] 53 | 54 | And create a `post-new` hook for notmuch to call afew: 55 | 56 | .. code-block:: sh 57 | 58 | $ notmuchdir=path/to/maildir/.notmuch 59 | $ mkdir -p "$notmuchdir/hooks" 60 | $ printf > "$notmuchdir/hooks/post-new" '#!/usr/bin/env sh\n$HOME/.local/bin/afew --tag --new\n' 61 | $ chmod u+x "$notmuchdir/hooks/post-new" 62 | 63 | Next Steps 64 | ---------- 65 | 66 | You can: 67 | 68 | * add extra :doc:`filters` for more custom filtering 69 | * make use of the :doc:`move_mode` to move your email between folders 70 | * run afew against all your old mail by running `afew --tag --all` 71 | * start :doc:`extending` afew 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ISC 2 | # Copyright (c) Justus Winter <4winter@informatik.uni-hamburg.de> 3 | 4 | import os 5 | 6 | from setuptools import setup, find_packages 7 | 8 | 9 | def get_requires(): 10 | if os.environ.get('TRAVIS') != 'true' and os.environ.get('READTHEDOCS') != 'True': 11 | yield 'notmuch2' 12 | yield 'chardet' 13 | yield 'dkimpy' 14 | 15 | with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'README.rst'), encoding='utf-8') as f: 16 | long_description = f.read() 17 | 18 | setup( 19 | name='afew', 20 | use_scm_version={'write_to': 'afew/version.py'}, 21 | description="An initial tagging script for notmuch mail", 22 | url="https://github.com/afewmail/afew", 23 | license="ISC", 24 | long_description=long_description, 25 | long_description_content_type="text/x-rst", 26 | setup_requires=['setuptools_scm'], 27 | packages=find_packages(), 28 | test_suite='afew.tests', 29 | package_data={ 30 | 'afew': ['defaults/afew.config'] 31 | }, 32 | entry_points={ 33 | 'console_scripts': [ 34 | 'afew = afew.commands:main'], 35 | 'afew.filter': [ 36 | 'Filter = afew.filters.BaseFilter:Filter', 37 | 'ArchiveSentMailsFilter = afew.filters.ArchiveSentMailsFilter:ArchiveSentMailsFilter', 38 | 'DKIMValidityFilter = afew.filters.DKIMValidityFilter:DKIMValidityFilter', 39 | 'DMARCReportInspectionFilter = afew.filters.DMARCReportInspectionFilter:DMARCReportInspectionFilter', 40 | 'FolderNameFilter = afew.filters.FolderNameFilter:FolderNameFilter', 41 | 'HeaderMatchingFilter = afew.filters.HeaderMatchingFilter:HeaderMatchingFilter', 42 | 'InboxFilter = afew.filters.InboxFilter:InboxFilter', 43 | 'KillThreadsFilter = afew.filters.KillThreadsFilter:KillThreadsFilter', 44 | 'ListMailsFilter = afew.filters.ListMailsFilter:ListMailsFilter', 45 | 'MeFilter = afew.filters.MeFilter:MeFilter', 46 | 'SentMailsFilter = afew.filters.SentMailsFilter:SentMailsFilter', 47 | 'SpamFilter = afew.filters.SpamFilter:SpamFilter', 48 | 'PropagateTagsByRegexInThreadFilter = afew.filters.PropagateTagsByRegexInThreadFilter:PropagateTagsByRegexInThreadFilter', 49 | 'PropagateTagsInThreadFilter = afew.filters.PropagateTagsInThreadFilter:PropagateTagsInThreadFilter', 50 | ], 51 | }, 52 | install_requires=list(get_requires()), 53 | tests_require=['freezegun'], 54 | provides=['afew'], 55 | classifiers=[ 56 | 'License :: OSI Approved :: ISC License (ISCL)', 57 | 'Development Status :: 4 - Beta', 58 | 'Environment :: Console', 59 | 'Intended Audience :: End Users/Desktop', 60 | 'Programming Language :: Python', 61 | 'Topic :: Communications :: Email', 62 | 'Topic :: Communications :: Email :: Filters', 63 | 'Topic :: Utilities', 64 | 'Topic :: Database', 65 | ], 66 | ) 67 | --------------------------------------------------------------------------------