Control: tags 1098664 + patch Dear maintainer,
I've prepared an NMU for afew (versioned as 3.0.1.post63-0.1) and uploaded it to DELAYED/5. Please feel free to tell me if I should delay it longer. Regards. David
diff -Nru afew-3.0.1/afew/configparser.py afew-3.0.1.post63/afew/configparser.py --- afew-3.0.1/afew/configparser.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/configparser.py 2025-03-15 10:46:03.000000000 -0300 @@ -17,7 +17,9 @@ return filter(None, result) -class ConfigParser(configparser.ConfigParser, GetListMixIn): pass +class ConfigParser(configparser.ConfigParser, GetListMixIn): + pass -class RawConfigParser(configparser.RawConfigParser, GetListMixIn): pass +class RawConfigParser(configparser.RawConfigParser, GetListMixIn): + pass diff -Nru afew-3.0.1/afew/Database.py afew-3.0.1.post63/afew/Database.py --- afew-3.0.1/afew/Database.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/Database.py 2025-03-15 10:46:03.000000000 -0300 @@ -97,7 +97,7 @@ def get_messages(self, query, full_thread=False): """ - Get all messages mathing the given query. + Get all messages matching the given query. :param query: the query to execute using :func:`Database.do_query` :type query: str @@ -126,7 +126,7 @@ # TODO: bindings are *very* unpythonic here... iterator *or* None # is a nono replies = message.get_replies() - if replies != None: + if replies is not None: for message in replies: # TODO: yield from for message in self.walk_replies(message): diff -Nru afew-3.0.1/afew/files.py afew-3.0.1.post63/afew/files.py --- afew-3.0.1/afew/files.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/files.py 2025-03-15 10:46:03.000000000 -0300 @@ -8,13 +8,14 @@ import platform import queue import threading +import notmuch +import pyinotify +import ctypes +import contextlib if platform.system() != 'Linux': raise ImportError('Unsupported platform: {!r}'.format(platform.system())) -import notmuch -import pyinotify - class EventHandler(pyinotify.ProcessEvent): def __init__(self, options, database): @@ -22,7 +23,7 @@ self.database = database super().__init__() - ignore_re = re.compile('(/xapian/.*(base.|tmp)$)|(\.lock$)|(/dovecot)') + ignore_re = re.compile(r'(/xapian/.*(base.|tmp)$)|(\.lock$)|(/dovecot)') def process_IN_DELETE(self, event): if self.ignore_re.search(event.pathname): @@ -45,17 +46,17 @@ filter_.run('id:"{}"'.format(message.get_message_id())) filter_.commit(self.options.dry_run) except Exception as e: - logging.warn('Error processing mail with filter {!r}: {}'.format(filter_.message, e)) + logging.warning('Error processing mail with filter {!r}: {}'.format(filter_.message, e)) try: self.database.add_message(event.pathname, sync_maildir_flags=True, new_mail_handler=new_mail) except notmuch.FileError as e: - logging.warn('Error opening mail file: {}'.format(e)) + logging.warning('Error opening mail file: {}'.format(e)) return except notmuch.FileNotEmailError as e: - logging.warn('File does not look like an email: {}'.format(e)) + logging.warning('File does not look like an email: {}'.format(e)) return else: if src_pathname: @@ -67,9 +68,9 @@ def watch_for_new_files(options, database, paths, daemonize=False): wm = pyinotify.WatchManager() mask = ( - pyinotify.IN_DELETE | - pyinotify.IN_MOVED_FROM | - pyinotify.IN_MOVED_TO) + pyinotify.IN_DELETE | + pyinotify.IN_MOVED_FROM | + pyinotify.IN_MOVED_TO) handler = EventHandler(options, database) notifier = pyinotify.Notifier(wm, handler) @@ -83,9 +84,6 @@ notifier.loop() -import ctypes -import contextlib - try: libc = ctypes.CDLL(ctypes.util.find_library("c")) except ImportError as e: @@ -171,7 +169,7 @@ if child not in blacklist): try: stat_result = os.stat(child_path) - except: + except Exception: continue if stat_result.st_mode & stat.S_IFDIR: @@ -193,7 +191,7 @@ while True: result = results.get() - if result != None: + if result is not None: yield result else: break diff -Nru afew-3.0.1/afew/filters/DMARCReportInspectionFilter.py afew-3.0.1.post63/afew/filters/DMARCReportInspectionFilter.py --- afew-3.0.1/afew/filters/DMARCReportInspectionFilter.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/filters/DMARCReportInspectionFilter.py 2025-03-15 10:46:03.000000000 -0300 @@ -81,6 +81,8 @@ :param node: XML node holding status as text. :returns: Whether the status is reported as "failed". """ + if not node or not node.text: + return True return (node.text.strip() not in ['pass', 'none']) @@ -118,17 +120,28 @@ class DMARCReportInspectionFilter(Filter): """ Inspect DMARC reports for DKIM and SPF status. + + Config: + + [DMARCReportInspectionFilter] + dkim_ok_tag = "dmarc/dkim-ok" + dkim_fail_tag = "dmarc/dkim-fail" + spf_ok_tag = "dmarc/spf-ok" + spf_fail_tag = "dmarc/spf-fail" + subject_regexp = "^report domain:" + """ def __init__(self, # pylint: disable=too-many-arguments database, dkim_ok_tag='dmarc/dkim-ok', dkim_fail_tag='dmarc/dkim-fail', spf_ok_tag='dmarc/spf-ok', - spf_fail_tag='dmarc/spf-fail'): + spf_fail_tag='dmarc/spf-fail', + subject_regexp=r'^report domain:'): super().__init__(database) self.dkim_tag = {True: dkim_ok_tag, False: dkim_fail_tag} self.spf_tag = {True: spf_ok_tag, False: spf_fail_tag} - self.dmarc_subject = re.compile(r'^report domain:', + self.dmarc_subject = re.compile(subject_regexp, flags=re.IGNORECASE) self.log = logging.getLogger('{}.{}'.format( self.__module__, self.__class__.__name__)) diff -Nru afew-3.0.1/afew/filters/FolderNameFilter.py afew-3.0.1.post63/afew/filters/FolderNameFilter.py --- afew-3.0.1/afew/filters/FolderNameFilter.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/filters/FolderNameFilter.py 2025-03-15 10:46:03.000000000 -0300 @@ -2,7 +2,6 @@ # Copyright (c) dtk <d...@gmx.de> from afew.filters.BaseFilter import Filter -from afew.NotmuchSettings import notmuch_settings import re import shlex @@ -15,7 +14,7 @@ super().__init__(database) self.__filename_pattern = '{mail_root}/(?P<maildirs>.*)/(cur|new)/[^/]+'.format( - mail_root=notmuch_settings.get('database', 'path').rstrip('/')) + mail_root=database.db_path.rstrip('/')) self.__folder_explicit_list = set(shlex.split(folder_explicit_list)) self.__folder_blacklist = set(shlex.split(folder_blacklist)) self.__folder_transforms = self.__parse_transforms(folder_transforms) diff -Nru afew-3.0.1/afew/filters/HeaderMatchingFilter.py afew-3.0.1.post63/afew/filters/HeaderMatchingFilter.py --- afew-3.0.1/afew/filters/HeaderMatchingFilter.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/filters/HeaderMatchingFilter.py 2025-03-15 10:46:03.000000000 -0300 @@ -6,6 +6,8 @@ from afew.filters.BaseFilter import Filter +from notmuch.errors import NullPointerError + import re @@ -22,10 +24,13 @@ def handle_message(self, message): if self.header is not None and self.pattern is not None: if not self._tag_blacklist.intersection(message.get_tags()): - value = message.get_header(self.header) - match = self.pattern.search(value) - if match: - sub = (lambda tag: - tag.format(**match.groupdict()).lower()) - self.remove_tags(message, *map(sub, self._tags_to_remove)) - self.add_tags(message, *map(sub, self._tags_to_add)) + try: + value = message.get_header(self.header) + match = self.pattern.search(value) + if match: + tagdict = {k: v.lower() for k, v in match.groupdict().items()} + sub = (lambda tag: tag.format(**tagdict)) + self.remove_tags(message, *map(sub, self._tags_to_remove)) + self.add_tags(message, *map(sub, self._tags_to_add)) + except NullPointerError: + pass diff -Nru afew-3.0.1/afew/filters/SentMailsFilter.py afew-3.0.1.post63/afew/filters/SentMailsFilter.py --- afew-3.0.1/afew/filters/SentMailsFilter.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/filters/SentMailsFilter.py 2025-03-15 10:46:03.000000000 -0300 @@ -21,11 +21,11 @@ my_addresses.add(other_email) self.query = ( - '(' + - ' OR '.join('from:"%s"' % address for address in my_addresses) - + ') AND NOT (' + - ' OR '.join('to:"%s"' % address for address in my_addresses) - + ')' + '(' + + ' OR '.join('from:"%s"' % address for address in my_addresses) + + ') AND NOT (' + + ' OR '.join('to:"%s"' % address for address in my_addresses) + + ')' ) self.sent_tag = sent_tag @@ -58,7 +58,7 @@ return email_to_tags def __get_bare_email(self, email): - if not '<' in email: + if '<' not in email: return email else: match = self._bare_email_re.search(email) diff -Nru afew-3.0.1/afew/MailMover.py afew-3.0.1.post63/afew/MailMover.py --- afew-3.0.1/afew/MailMover.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/MailMover.py 2025-03-15 10:46:03.000000000 -0300 @@ -80,7 +80,7 @@ shutil.copy2(fname, self.get_new_name(fname, destination)) to_delete_fnames.append(fname) except shutil.SameFileError: - logging.warn("trying to move '{}' onto itself".format(fname)) + logging.warning("trying to move '{}' onto itself".format(fname)) continue except shutil.Error as e: # this is ugly, but shutil does not provide more diff -Nru afew-3.0.1/afew/NotmuchSettings.py afew-3.0.1.post63/afew/NotmuchSettings.py --- afew-3.0.1/afew/NotmuchSettings.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/NotmuchSettings.py 2025-03-15 10:46:03.000000000 -0300 @@ -9,14 +9,15 @@ def read_notmuch_settings(path=None): - if path == None: + if path is None: path = os.environ.get('NOTMUCH_CONFIG', os.path.expanduser('~/.notmuch-config')) with open(path) as fp: notmuch_settings.read_file(fp) -def write_notmuch_settings(path = None): - if path == None: + +def write_notmuch_settings(path=None): + if path is None: path = os.environ.get('NOTMUCH_CONFIG', os.path.expanduser('~/.notmuch-config')) with open(path, 'w+') as fp: diff -Nru afew-3.0.1/afew/tests/test_headermatchingfilter.py afew-3.0.1.post63/afew/tests/test_headermatchingfilter.py --- afew-3.0.1/afew/tests/test_headermatchingfilter.py 1969-12-31 20:00:00.000000000 -0400 +++ afew-3.0.1.post63/afew/tests/test_headermatchingfilter.py 2025-03-15 10:46:03.000000000 -0300 @@ -0,0 +1,84 @@ +"""Test suite for DKIMValidityFilter. +""" +import unittest +from email.utils import make_msgid +from unittest import mock + +from afew.Database import Database +from afew.filters.HeaderMatchingFilter import HeaderMatchingFilter + +from notmuch.errors import NullPointerError + + +class _AddTags: # pylint: disable=too-few-public-methods + """Mock for `add_tags` method of base filter. We need to easily collect + tags added by filter for test assertion. + """ + def __init__(self, tags): + self._tags = tags + + def __call__(self, message, *tags): + self._tags.update(tags) + + +def _make_header_matching_filter(): + """Make `HeaderMatchingFilter` with mocked `HeaderMatchingFilter.add_tags` + method, so in tests we can easily check what tags were added by filter + without fiddling with db. + """ + tags = set() + add_tags = _AddTags(tags) + header_filter = HeaderMatchingFilter(Database(), header="X-test", pattern="") + header_filter.add_tags = add_tags + return header_filter, tags + + +def _make_message(should_fail): + """Make mock email Message. + + Mocked methods: + + - `get_header()` returns non-empty string. When testing with mocked + function for verifying DKIM signature, DKIM signature doesn't matter as + long as it's non-empty string. + + - `get_filenames()` returns list of non-empty string. When testing with + mocked file open, it must just be non-empty string. + + - `get_message_id()` returns some generated message ID. + """ + message = mock.Mock() + if should_fail: + message.get_header.side_effect = NullPointerError + else: + message.get_header.return_value = 'header' + message.get_filenames.return_value = ['a'] + message.get_tags.return_value = ['a'] + message.get_message_id.return_value = make_msgid() + return message + + +class TestHeaderMatchingFilter(unittest.TestCase): + """Test suite for `HeaderMatchingFilter`. + """ + @mock.patch('afew.filters.HeaderMatchingFilter.open', + mock.mock_open(read_data=b'')) + def test_header_exists(self): + """Test message with header that exists. + """ + header_filter, tags = _make_header_matching_filter() + message = _make_message(False) + header_filter.handle_message(message) + + self.assertSetEqual(tags, set()) + + @mock.patch('afew.filters.HeaderMatchingFilter.open', + mock.mock_open(read_data=b'')) + def test_header_doesnt_exist(self): + """Test message with header that exists. + """ + header_filter, tags = _make_header_matching_filter() + message = _make_message(True) + header_filter.handle_message(message) + + self.assertSetEqual(tags, set()) diff -Nru afew-3.0.1/afew/tests/test_mailmover.py afew-3.0.1.post63/afew/tests/test_mailmover.py --- afew-3.0.1/afew/tests/test_mailmover.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/tests/test_mailmover.py 2025-03-15 10:46:03.000000000 -0300 @@ -4,7 +4,6 @@ from email.utils import make_msgid from freezegun import freeze_time import mailbox -import notmuch import os import shutil import tempfile @@ -13,6 +12,7 @@ from afew.Database import Database from afew.NotmuchSettings import notmuch_settings, write_notmuch_settings + def create_mail(msg, maildir, notmuch_db, tags, old=False): email_message = email.message.EmailMessage() # freezegun doesn't handle time zones properly when generating UNIX @@ -83,11 +83,9 @@ }, } - def tearDown(self): shutil.rmtree(self.test_dir) - @staticmethod def get_folder_content(db, folder): return { @@ -95,7 +93,6 @@ for msg in db.do_query('folder:{}'.format(folder)).search_messages() } - def test_all_rule_cases(self): from afew import MailMover @@ -132,7 +129,6 @@ self.assertEqual(expect_archive, self.get_folder_content(db, '.archive')) self.assertEqual(expect_spam, self.get_folder_content(db, '.spam')) - def test_max_age(self): from afew import MailMover diff -Nru afew-3.0.1/afew/tests/test_utils.py afew-3.0.1.post63/afew/tests/test_utils.py --- afew-3.0.1/afew/tests/test_utils.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/tests/test_utils.py 1969-12-31 20:00:00.000000000 -0400 @@ -1,12 +0,0 @@ -# -import unittest - -from afew import utils - - -class TestUtils(unittest.TestCase): - pass - - -if __name__ == '__main__': - unittest.main() diff -Nru afew-3.0.1/afew/version.py afew-3.0.1.post63/afew/version.py --- afew-3.0.1/afew/version.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew/version.py 1969-12-31 20:00:00.000000000 -0400 @@ -1,4 +0,0 @@ -# coding: utf-8 -# file generated by setuptools_scm -# don't change, don't track in version control -version = '3.0.1' diff -Nru afew-3.0.1/afew.egg-info/dependency_links.txt afew-3.0.1.post63/afew.egg-info/dependency_links.txt --- afew-3.0.1/afew.egg-info/dependency_links.txt 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew.egg-info/dependency_links.txt 1969-12-31 20:00:00.000000000 -0400 @@ -1 +0,0 @@ - diff -Nru afew-3.0.1/afew.egg-info/entry_points.txt afew-3.0.1.post63/afew.egg-info/entry_points.txt --- afew-3.0.1/afew.egg-info/entry_points.txt 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew.egg-info/entry_points.txt 1969-12-31 20:00:00.000000000 -0400 @@ -1,19 +0,0 @@ -[afew.filter] -ArchiveSentMailsFilter = afew.filters.ArchiveSentMailsFilter:ArchiveSentMailsFilter -DKIMValidityFilter = afew.filters.DKIMValidityFilter:DKIMValidityFilter -DMARCReportInspectionFilter = afew.filters.DMARCReportInspectionFilter:DMARCReportInspectionFilter -Filter = afew.filters.BaseFilter:Filter -FolderNameFilter = afew.filters.FolderNameFilter:FolderNameFilter -HeaderMatchingFilter = afew.filters.HeaderMatchingFilter:HeaderMatchingFilter -InboxFilter = afew.filters.InboxFilter:InboxFilter -KillThreadsFilter = afew.filters.KillThreadsFilter:KillThreadsFilter -ListMailsFilter = afew.filters.ListMailsFilter:ListMailsFilter -MeFilter = afew.filters.MeFilter:MeFilter -PropagateTagsByRegexInThreadFilter = afew.filters.PropagateTagsByRegexInThreadFilter:PropagateTagsByRegexInThreadFilter -PropagateTagsInThreadFilter = afew.filters.PropagateTagsInThreadFilter:PropagateTagsInThreadFilter -SentMailsFilter = afew.filters.SentMailsFilter:SentMailsFilter -SpamFilter = afew.filters.SpamFilter:SpamFilter - -[console_scripts] -afew = afew.commands:main - diff -Nru afew-3.0.1/afew.egg-info/PKG-INFO afew-3.0.1.post63/afew.egg-info/PKG-INFO --- afew-3.0.1/afew.egg-info/PKG-INFO 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew.egg-info/PKG-INFO 1969-12-31 20:00:00.000000000 -0400 @@ -1,20 +0,0 @@ -Metadata-Version: 1.1 -Name: afew -Version: 3.0.1 -Summary: An initial tagging script for notmuch mail -Home-page: https://github.com/afewmail/afew -Author: UNKNOWN -Author-email: UNKNOWN -License: ISC -Description: UNKNOWN -Platform: UNKNOWN -Classifier: License :: OSI Approved :: ISC License (ISCL) -Classifier: Development Status :: 4 - Beta -Classifier: Environment :: Console -Classifier: Intended Audience :: End Users/Desktop -Classifier: Programming Language :: Python -Classifier: Topic :: Communications :: Email -Classifier: Topic :: Communications :: Email :: Filters -Classifier: Topic :: Utilities -Classifier: Topic :: Database -Provides: afew diff -Nru afew-3.0.1/afew.egg-info/requires.txt afew-3.0.1.post63/afew.egg-info/requires.txt --- afew-3.0.1/afew.egg-info/requires.txt 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew.egg-info/requires.txt 1969-12-31 20:00:00.000000000 -0400 @@ -1,3 +0,0 @@ -notmuch -chardet -dkimpy diff -Nru afew-3.0.1/afew.egg-info/SOURCES.txt afew-3.0.1.post63/afew.egg-info/SOURCES.txt --- afew-3.0.1/afew.egg-info/SOURCES.txt 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew.egg-info/SOURCES.txt 1969-12-31 20:00:00.000000000 -0400 @@ -1,57 +0,0 @@ -.gitignore -LICENSE -NEWS.md -README.rst -setup.py -.github/workflows/build.yml -afew/Database.py -afew/FilterRegistry.py -afew/MailMover.py -afew/NotmuchSettings.py -afew/Settings.py -afew/__init__.py -afew/__main__.py -afew/commands.py -afew/configparser.py -afew/files.py -afew/main.py -afew/utils.py -afew/version.py -afew.egg-info/PKG-INFO -afew.egg-info/SOURCES.txt -afew.egg-info/dependency_links.txt -afew.egg-info/entry_points.txt -afew.egg-info/requires.txt -afew.egg-info/top_level.txt -afew/defaults/afew.config -afew/filters/ArchiveSentMailsFilter.py -afew/filters/BaseFilter.py -afew/filters/DKIMValidityFilter.py -afew/filters/DMARCReportInspectionFilter.py -afew/filters/FolderNameFilter.py -afew/filters/HeaderMatchingFilter.py -afew/filters/InboxFilter.py -afew/filters/KillThreadsFilter.py -afew/filters/ListMailsFilter.py -afew/filters/MeFilter.py -afew/filters/PropagateTagsByRegexInThreadFilter.py -afew/filters/PropagateTagsInThreadFilter.py -afew/filters/SentMailsFilter.py -afew/filters/SpamFilter.py -afew/filters/__init__.py -afew/tests/__init__.py -afew/tests/test_dkimvalidityfilter.py -afew/tests/test_mailmover.py -afew/tests/test_settings.py -afew/tests/test_utils.py -docs/commandline.rst -docs/conf.py -docs/configuration.rst -docs/extending.rst -docs/filters.rst -docs/implementation.rst -docs/index.rst -docs/installation.rst -docs/move_mode.rst -docs/quickstart.rst -docs/_static/.keep \ No newline at end of file diff -Nru afew-3.0.1/afew.egg-info/top_level.txt afew-3.0.1.post63/afew.egg-info/top_level.txt --- afew-3.0.1/afew.egg-info/top_level.txt 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/afew.egg-info/top_level.txt 1969-12-31 20:00:00.000000000 -0400 @@ -1 +0,0 @@ -afew diff -Nru afew-3.0.1/debian/changelog afew-3.0.1.post63/debian/changelog --- afew-3.0.1/debian/changelog 2025-02-15 20:54:57.000000000 -0400 +++ afew-3.0.1.post63/debian/changelog 2025-03-15 11:07:48.000000000 -0300 @@ -1,3 +1,12 @@ +afew (3.0.1.post63-0.1) unstable; urgency=medium + + * Non-maintainer upload + * New upstream snaphot (upstream commit 65227fa) + * Apply upstream pull request #350 (needed for python 3.12) + * Apply upstream pull request #320 (Closes: #1098664). + + -- David Bremner <brem...@debian.org> Sat, 15 Mar 2025 11:07:48 -0300 + afew (3.0.1-6) unstable; urgency=medium * Team Upload diff -Nru afew-3.0.1/debian/control afew-3.0.1.post63/debian/control --- afew-3.0.1/debian/control 2025-02-15 20:33:02.000000000 -0400 +++ afew-3.0.1.post63/debian/control 2025-03-15 11:07:48.000000000 -0300 @@ -11,7 +11,7 @@ python3-dkim, python3-dnspython, python3-freezegun, - python3-notmuch, + python3-notmuch2, python3-pytest, python3-setuptools, python3-setuptools-scm, diff -Nru afew-3.0.1/debian/patches/apply-upstream-pull-request-320.patch afew-3.0.1.post63/debian/patches/apply-upstream-pull-request-320.patch --- afew-3.0.1/debian/patches/apply-upstream-pull-request-320.patch 1969-12-31 20:00:00.000000000 -0400 +++ afew-3.0.1.post63/debian/patches/apply-upstream-pull-request-320.patch 2025-03-15 11:07:48.000000000 -0300 @@ -0,0 +1,671 @@ +From: David Bremner <brem...@debian.org> +Date: Sat, 15 Mar 2025 11:05:38 -0300 +Subject: Apply upstream pull request 320 + +This is labelled draft upstream, but seems to be tested by mjg. +--- + .github/workflows/build.yml | 6 ++--- + afew/Database.py | 31 ++++++++++------------ + afew/MailMover.py | 8 +++--- + afew/files.py | 10 +++---- + afew/filters/BaseFilter.py | 20 +++++++------- + afew/filters/DKIMValidityFilter.py | 10 ++++--- + afew/filters/DMARCReportInspectionFilter.py | 4 +-- + afew/filters/FolderNameFilter.py | 10 ++++--- + afew/filters/HeaderMatchingFilter.py | 8 +++--- + afew/filters/KillThreadsFilter.py | 2 +- + afew/filters/MeFilter.py | 2 +- + afew/filters/PropagateTagsByRegexInThreadFilter.py | 4 +-- + afew/filters/PropagateTagsInThreadFilter.py | 2 +- + afew/filters/SentMailsFilter.py | 5 +++- + afew/tests/test_dkimvalidityfilter.py | 20 +++++++------- + afew/tests/test_headermatchingfilter.py | 18 ++++++------- + afew/tests/test_mailmover.py | 15 ++++++----- + afew/utils.py | 12 ++++++--- + docs/extending.rst | 2 +- + setup.py | 2 +- + 20 files changed, 103 insertions(+), 88 deletions(-) + +diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml +index 2951a12..d24577f 100644 +--- a/.github/workflows/build.yml ++++ b/.github/workflows/build.yml +@@ -9,7 +9,7 @@ jobs: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + name: Build (Python ${{ matrix.python-version }}) +- runs-on: ubuntu-20.04 ++ runs-on: ubuntu-21.04 + steps: + - uses: actions/checkout@v2 + with: +@@ -24,11 +24,11 @@ jobs: + shell: bash + run: | + sudo apt-get update +- sudo apt-get install -y notmuch python3-notmuch python3-venv flake8 ++ sudo apt-get install -y notmuch python3-notmuch2 python3-venv flake8 + python3 -m venv env + source ./env/bin/activate + pip install setuptools setuptools_scm pytest dkimpy +- ln -s /usr/lib/python3/dist-packages/notmuch ./env/lib/python*/site-packages ++ ln -s /usr/lib/python3/dist-packages/notmuch2 ./env/lib/python*/site-packages + - name: flake8 lint + run: | + source ./env/bin/activate +diff --git a/afew/Database.py b/afew/Database.py +index ee9812e..208ed2b 100644 +--- a/afew/Database.py ++++ b/afew/Database.py +@@ -5,7 +5,7 @@ import os + import time + import logging + +-import notmuch ++import notmuch2 + + from afew.NotmuchSettings import notmuch_settings, get_notmuch_new_tags + +@@ -46,19 +46,18 @@ class Database: + """ + self.close() + +- def open(self, rw=False, retry_for=180, retry_delay=1, create=False): ++ def open(self, rw=False, retry_for=180, retry_delay=1): + if rw: +- if self.handle and self.handle.mode == notmuch.Database.MODE.READ_WRITE: ++ if self.handle and self.handle.mode == notmuch2.Database.MODE.READ_WRITE: + return self.handle + + start_time = time.time() + while True: + try: +- self.handle = notmuch.Database(self.db_path, +- mode=notmuch.Database.MODE.READ_WRITE, +- create=create) ++ self.handle = notmuch2.Database(self.db_path, ++ mode=notmuch2.Database.MODE.READ_WRITE) + break +- except notmuch.NotmuchError: ++ except notmuch2.NotmuchError: + time_left = int(retry_for - (time.time() - start_time)) + + if time_left <= 0: +@@ -71,7 +70,8 @@ class Database: + time.sleep(retry_delay) + else: + if not self.handle: +- self.handle = notmuch.Database(self.db_path, create=create) ++ self.handle = notmuch2.Database(self.db_path, ++ mode=notmuch2.Database.MODE.READ_ONLY) + + return self.handle + +@@ -93,7 +93,7 @@ class Database: + :rtype: :class:`notmuch.Query` + """ + logging.debug('Executing query %r' % query) +- return notmuch.Query(self.open(), query) ++ return notmuch2.Database.messages(self.open(), query) + + def get_messages(self, query, full_thread=False): + """ +@@ -106,10 +106,10 @@ class Database: + :returns: an iterator over :class:`notmuch.Message` objects + """ + if not full_thread: +- for message in self.do_query(query).search_messages(): ++ for message in self.do_query(query): + yield message + else: +- for thread in self.do_query(query).search_threads(): ++ for thread in self.do_query(query): + for message in self.walk_thread(thread): + yield message + +@@ -163,16 +163,13 @@ class Database: + """ + # TODO: it would be nice to update notmuchs directory index here + handle = self.open(rw=True) +- if hasattr(notmuch.Database, 'index_file'): +- message, status = handle.index_file(path, sync_maildir_flags=sync_maildir_flags) +- else: +- message, status = handle.add_message(path, sync_maildir_flags=sync_maildir_flags) ++ message, duplicate = handle.add(path, sync_flags=sync_maildir_flags) + +- if status != notmuch.STATUS.DUPLICATE_MESSAGE_ID: ++ if not duplicate: + logging.info('Found new mail in {}'.format(path)) + + for tag in get_notmuch_new_tags(): +- message.add_tag(tag) ++ message.tags.add(tag) + + if new_mail_handler: + new_mail_handler(message) +diff --git a/afew/MailMover.py b/afew/MailMover.py +index 81caec0..5b11394 100644 +--- a/afew/MailMover.py ++++ b/afew/MailMover.py +@@ -8,8 +8,6 @@ import uuid + from datetime import date, datetime, timedelta + from subprocess import check_call, CalledProcessError, DEVNULL + +-import notmuch +- + from afew.Database import Database + from afew.utils import get_message_summary + +@@ -21,7 +19,7 @@ class MailMover(Database): + + def __init__(self, max_age=0, rename=False, dry_run=False, notmuch_args='', quiet=False): + super().__init__() +- self.db = notmuch.Database(self.db_path) ++ self.db = Database() + self.query = 'folder:"{folder}" AND {subquery}' + if max_age: + days = timedelta(int(max_age)) +@@ -61,11 +59,11 @@ class MailMover(Database): + main_query = self.query.format( + folder=maildir.replace("\"", "\\\""), subquery=query) + logging.debug("query: {}".format(main_query)) +- messages = notmuch.Query(self.db, main_query).search_messages() ++ messages = self.db.get_messages(main_query) + for message in messages: + # a single message (identified by Message-ID) can be in several + # places; only touch the one(s) that exists in this maildir +- all_message_fnames = message.get_filenames() ++ all_message_fnames = (str(name) for name in message.filenames()) + to_move_fnames = [name for name in all_message_fnames + if maildir in name] + if not to_move_fnames: +diff --git a/afew/files.py b/afew/files.py +index 8186c09..475b65a 100644 +--- a/afew/files.py ++++ b/afew/files.py +@@ -8,7 +8,7 @@ import logging + import platform + import queue + import threading +-import notmuch ++import notmuch2 + import pyinotify + import ctypes + import contextlib +@@ -43,19 +43,19 @@ class EventHandler(pyinotify.ProcessEvent): + def new_mail(message): + for filter_ in self.options.enable_filters: + try: +- filter_.run('id:"{}"'.format(message.get_message_id())) ++ filter_.run('id:"{}"'.format(message.messageid)) + filter_.commit(self.options.dry_run) + except Exception as e: + logging.warning('Error processing mail with filter {!r}: {}'.format(filter_.message, e)) + + try: + self.database.add_message(event.pathname, +- sync_maildir_flags=True, ++ sync_flags=True, + new_mail_handler=new_mail) +- except notmuch.FileError as e: ++ except notmuch2.FileError as e: + logging.warning('Error opening mail file: {}'.format(e)) + return +- except notmuch.FileNotEmailError as e: ++ except notmuch2.FileNotEmailError as e: + logging.warning('File does not look like an email: {}'.format(e)) + return + else: +diff --git a/afew/filters/BaseFilter.py b/afew/filters/BaseFilter.py +index b4e52ea..3803d7b 100644 +--- a/afew/filters/BaseFilter.py ++++ b/afew/filters/BaseFilter.py +@@ -55,27 +55,27 @@ class Filter: + self.handle_message(message) + + def handle_message(self, message): +- if not self._tag_blacklist.intersection(message.get_tags()): ++ if not self._tag_blacklist.intersection(message.tags): + self.remove_tags(message, *self._tags_to_remove) + self.add_tags(message, *self._tags_to_add) + + def add_tags(self, message, *tags): + if tags: + self.log.debug('Adding tags %s to id:%s' % (', '.join(tags), +- message.get_message_id())) +- self._add_tags[message.get_message_id()].update(tags) ++ message.messageid)) ++ self._add_tags[message.messageid].update(tags) + + def remove_tags(self, message, *tags): + if tags: + filtered_tags = list(tags) + self.log.debug('Removing tags %s from id:%s' % (', '.join(filtered_tags), +- message.get_message_id())) +- self._remove_tags[message.get_message_id()].update(filtered_tags) ++ message.messageid)) ++ self._remove_tags[message.messageid].update(filtered_tags) + + def flush_tags(self, message): + self.log.debug('Removing all tags from id:%s' % +- message.get_message_id()) +- self._flush_tags.append(message.get_message_id()) ++ message.messageid) ++ self._flush_tags.append(message.messageid) + + def commit(self, dry_run=True): + dirty_messages = set() +@@ -93,15 +93,15 @@ class Filter: + db = self.database.open(rw=True) + + for message_id in dirty_messages: +- message = db.find_message(message_id) ++ message = db.find(message_id) + + if message_id in self._flush_tags: + message.remove_all_tags() + + for tag in self._add_tags.get(message_id, []): +- message.add_tag(tag) ++ message.tags.add(tag) + + for tag in self._remove_tags.get(message_id, []): +- message.remove_tag(tag) ++ message.tags.remove(tag) + + self.flush_changes() +diff --git a/afew/filters/DKIMValidityFilter.py b/afew/filters/DKIMValidityFilter.py +index ad7be1a..46f8138 100644 +--- a/afew/filters/DKIMValidityFilter.py ++++ b/afew/filters/DKIMValidityFilter.py +@@ -50,14 +50,18 @@ class DKIMValidityFilter(Filter): + self.__module__, self.__class__.__name__)) + + def handle_message(self, message): +- if message.get_header(self.header): ++ try: ++ selfhead = message.header(self.header) ++ except LookupError: ++ selfhead = '' ++ if selfhead: + try: +- dkim_ok = all(map(verify_dkim, message.get_filenames())) ++ dkim_ok = all(map(verify_dkim, message.filenames())) + except DKIMVerifyError as verify_error: + self.log.warning( + "Failed to verify DKIM of '%s': %s " + "(marked as 'dkim-fail')", +- message.get_message_id(), ++ message.messageid, + verify_error + ) + dkim_ok = False +diff --git a/afew/filters/DMARCReportInspectionFilter.py b/afew/filters/DMARCReportInspectionFilter.py +index f3cbda1..24d423b 100644 +--- a/afew/filters/DMARCReportInspectionFilter.py ++++ b/afew/filters/DMARCReportInspectionFilter.py +@@ -147,7 +147,7 @@ class DMARCReportInspectionFilter(Filter): + self.__module__, self.__class__.__name__)) + + def handle_message(self, message): +- if not self.dmarc_subject.match(message.get_header('Subject')): ++ if not self.dmarc_subject.match(message.header('Subject')): + return + + auth_results = {'dkim': True, 'spf': True} +@@ -165,6 +165,6 @@ class DMARCReportInspectionFilter(Filter): + except DMARCInspectionError as inspection_error: + self.log.error( + "Failed to verify DMARC report of '%s': %s (not tagging)", +- message.get_message_id(), ++ message.messageid, + inspection_error + ) +diff --git a/afew/filters/FolderNameFilter.py b/afew/filters/FolderNameFilter.py +index af8ed78..6b2a535 100644 +--- a/afew/filters/FolderNameFilter.py ++++ b/afew/filters/FolderNameFilter.py +@@ -24,8 +24,8 @@ class FolderNameFilter(Filter): + def handle_message(self, message): + # Find all the dirs in the mail directory that this message + # belongs to +- maildirs = [re.match(self.__filename_pattern, filename) +- for filename in message.get_filenames()] ++ maildirs = [re.match(self.__filename_pattern, str(filename)) ++ for filename in message.filenames()] + maildirs = filter(None, maildirs) + if maildirs: + # Make the folders relative to mail_root and split them. +@@ -34,8 +34,12 @@ class FolderNameFilter(Filter): + folders = set([folder + for folder_group in folder_groups + for folder in folder_group]) ++ try: ++ subject = message.header('subject') ++ except LookupError: ++ subject = '' + self.log.debug('found folders {} for message {!r}'.format( +- folders, message.get_header('subject'))) ++ folders, subject)) + + # remove blacklisted folders + clean_folders = folders - self.__folder_blacklist +diff --git a/afew/filters/HeaderMatchingFilter.py b/afew/filters/HeaderMatchingFilter.py +index 4cbcf86..6bd6bbb 100644 +--- a/afew/filters/HeaderMatchingFilter.py ++++ b/afew/filters/HeaderMatchingFilter.py +@@ -6,7 +6,7 @@ + + from afew.filters.BaseFilter import Filter + +-from notmuch.errors import NullPointerError ++from notmuch2._errors import NullPointerError + + import re + +@@ -23,14 +23,14 @@ class HeaderMatchingFilter(Filter): + + def handle_message(self, message): + if self.header is not None and self.pattern is not None: +- if not self._tag_blacklist.intersection(message.get_tags()): ++ if not self._tag_blacklist.intersection(message.tags): + try: +- value = message.get_header(self.header) ++ value = message.header(self.header) + match = self.pattern.search(value) + if match: + tagdict = {k: v.lower() for k, v in match.groupdict().items()} + sub = (lambda tag: tag.format(**tagdict)) + self.remove_tags(message, *map(sub, self._tags_to_remove)) + self.add_tags(message, *map(sub, self._tags_to_add)) +- except NullPointerError: ++ except (NullPointerError, LookupError): + pass +diff --git a/afew/filters/KillThreadsFilter.py b/afew/filters/KillThreadsFilter.py +index a9187bb..08a5b9f 100644 +--- a/afew/filters/KillThreadsFilter.py ++++ b/afew/filters/KillThreadsFilter.py +@@ -9,7 +9,7 @@ class KillThreadsFilter(Filter): + query = 'NOT tag:killed' + + def handle_message(self, message): +- query = self.database.get_messages('thread:"%s" AND tag:killed' % message.get_thread_id()) ++ query = self.database.get_messages('thread:"%s" AND tag:killed' % message.threadid) + + if len(list(query)): + self.add_tags(message, 'killed') +diff --git a/afew/filters/MeFilter.py b/afew/filters/MeFilter.py +index 38b1c97..6ddded3 100644 +--- a/afew/filters/MeFilter.py ++++ b/afew/filters/MeFilter.py +@@ -26,5 +26,5 @@ class MeFilter(Filter): + self.me_tag = me_tag + + def handle_message(self, message): +- if not self._tag_blacklist.intersection(message.get_tags()): ++ if not self._tag_blacklist.intersection(message.tags): + self.add_tags(message, self.me_tag) +diff --git a/afew/filters/PropagateTagsByRegexInThreadFilter.py b/afew/filters/PropagateTagsByRegexInThreadFilter.py +index 7587c67..ea59590 100644 +--- a/afew/filters/PropagateTagsByRegexInThreadFilter.py ++++ b/afew/filters/PropagateTagsByRegexInThreadFilter.py +@@ -39,7 +39,7 @@ class PropagateTagsByRegexInThreadFilter(Filter): + """ + + def handle_message(self, message): +- thread_query = 'thread:"%s"' % (message.get_thread_id(),) ++ thread_query = 'thread:"%s"' % (message.threadid,) + if self._filter: + query = self.database.get_messages("(%s) AND (%s)" % (thread_query, self._filter)) + else: +@@ -50,7 +50,7 @@ class PropagateTagsByRegexInThreadFilter(Filter): + messages = list(query) + + # flatten tags +- tags_in_thread_t = {m.get_tags() for m in messages} # a set of Tags instances ++ tags_in_thread_t = {m.tags for m in messages} # a set of Tags instances + tags_in_thread = set(_flatten(tags_in_thread_t)) + + # filter tags +diff --git a/afew/filters/PropagateTagsInThreadFilter.py b/afew/filters/PropagateTagsInThreadFilter.py +index a87c34e..f25da48 100644 +--- a/afew/filters/PropagateTagsInThreadFilter.py ++++ b/afew/filters/PropagateTagsInThreadFilter.py +@@ -20,7 +20,7 @@ class PropagateTagsInThreadFilter(Filter): + + def handle_message(self, message): + for tag in self._propagate_tags: +- tag_query = 'thread:"%s" AND is:"%s"' % (message.get_thread_id(), tag) ++ tag_query = 'thread:"%s" AND is:"%s"' % (message.threadid, tag) + if self._filter: + query = self.database.get_messages("(%s) AND (%s)" % (tag_query, self._filter)) + else: +diff --git a/afew/filters/SentMailsFilter.py b/afew/filters/SentMailsFilter.py +index 78ce3bf..a948bc3 100644 +--- a/afew/filters/SentMailsFilter.py ++++ b/afew/filters/SentMailsFilter.py +@@ -38,7 +38,10 @@ class SentMailsFilter(Filter): + self.add_tags(message, self.sent_tag) + if self.to_transforms: + for header in ('To', 'Cc', 'Bcc'): +- email = self.__get_bare_email(message.get_header(header)) ++ try: ++ email = self.__get_bare_email(message.header(header)) ++ except LookupError: ++ email = '' + for tag in self.__pick_tags(email): + self.add_tags(message, tag) + else: +diff --git a/afew/tests/test_dkimvalidityfilter.py b/afew/tests/test_dkimvalidityfilter.py +index 2596728..9e39334 100644 +--- a/afew/tests/test_dkimvalidityfilter.py ++++ b/afew/tests/test_dkimvalidityfilter.py +@@ -39,19 +39,19 @@ def _make_message(): + + Mocked methods: + +- - `get_header()` returns non-empty string. When testing with mocked ++ - `header()` returns non-empty string. When testing with mocked + function for verifying DKIM signature, DKIM signature doesn't matter as + long as it's non-empty string. + +- - `get_filenames()` returns list of non-empty string. When testing with ++ - `filenames()` returns list of non-empty string. When testing with + mocked file open, it must just be non-empty string. + +- - `get_message_id()` returns some generated message ID. ++ - `messageid` returns some generated message ID. + """ + message = mock.Mock() +- message.get_header.return_value = 'sig' +- message.get_filenames.return_value = ['a'] +- message.get_message_id.return_value = make_msgid() ++ message.header.return_value = 'sig' ++ message.filenames.return_value = ['a'] ++ message.messageid = make_msgid() + return message + + +@@ -65,7 +65,7 @@ class TestDKIMValidityFilter(unittest.TestCase): + """ + dkim_filter, tags = _make_dkim_validity_filter() + message = _make_message() +- message.get_header.return_value = False ++ message.header.return_value = False + + with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ + as dkim_verify: +@@ -82,7 +82,7 @@ class TestDKIMValidityFilter(unittest.TestCase): + """ + dkim_filter, tags = _make_dkim_validity_filter() + message = _make_message() +- message.get_filenames.return_value = ['a', 'b', 'c'] ++ message.filenames.return_value = ['a', 'b', 'c'] + + with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ + as dkim_verify: +@@ -99,7 +99,7 @@ class TestDKIMValidityFilter(unittest.TestCase): + """ + dkim_filter, tags = _make_dkim_validity_filter() + message = _make_message() +- message.get_filenames.return_value = ['a', 'b', 'c'] ++ message.filenames.return_value = ['a', 'b', 'c'] + + with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ + as dkim_verify: +@@ -116,7 +116,7 @@ class TestDKIMValidityFilter(unittest.TestCase): + """ + dkim_filter, tags = _make_dkim_validity_filter() + message = _make_message() +- message.get_filenames.return_value = ['a', 'b', 'c'] ++ message.filenames.return_value = ['a', 'b', 'c'] + + with mock.patch('afew.filters.DKIMValidityFilter.dkim.verify') \ + as dkim_verify: +diff --git a/afew/tests/test_headermatchingfilter.py b/afew/tests/test_headermatchingfilter.py +index 8b09a67..fee1f4b 100644 +--- a/afew/tests/test_headermatchingfilter.py ++++ b/afew/tests/test_headermatchingfilter.py +@@ -7,7 +7,7 @@ from unittest import mock + from afew.Database import Database + from afew.filters.HeaderMatchingFilter import HeaderMatchingFilter + +-from notmuch.errors import NullPointerError ++from notmuch2._errors import NullPointerError + + + class _AddTags: # pylint: disable=too-few-public-methods +@@ -38,23 +38,23 @@ def _make_message(should_fail): + + Mocked methods: + +- - `get_header()` returns non-empty string. When testing with mocked ++ - `header()` returns non-empty string. When testing with mocked + function for verifying DKIM signature, DKIM signature doesn't matter as + long as it's non-empty string. + +- - `get_filenames()` returns list of non-empty string. When testing with ++ - `filenames()` returns list of non-empty string. When testing with + mocked file open, it must just be non-empty string. + +- - `get_message_id()` returns some generated message ID. ++ - `message` returns some generated message ID. + """ + message = mock.Mock() + if should_fail: +- message.get_header.side_effect = NullPointerError ++ message.header.side_effect = NullPointerError + else: +- message.get_header.return_value = 'header' +- message.get_filenames.return_value = ['a'] +- message.get_tags.return_value = ['a'] +- message.get_message_id.return_value = make_msgid() ++ message.header.return_value = 'header' ++ message.filenames.return_value = ['a'] ++ message.tags = ['a'] ++ message.messageid = make_msgid() + return message + + +diff --git a/afew/tests/test_mailmover.py b/afew/tests/test_mailmover.py +index b4f240e..a8ffdc7 100644 +--- a/afew/tests/test_mailmover.py ++++ b/afew/tests/test_mailmover.py +@@ -8,6 +8,7 @@ import os + import shutil + import tempfile + import unittest ++import notmuch2 + + from afew.Database import Database + from afew.NotmuchSettings import notmuch_settings, write_notmuch_settings +@@ -35,7 +36,7 @@ def create_mail(msg, maildir, notmuch_db, tags, old=False): + fname = os.path.join(maildir._path, maildir._lookup(message_key)) + notmuch_msg = notmuch_db.add_message(fname) + for tag in tags: +- notmuch_msg.add_tag(tag, False) ++ notmuch_msg.tags.add(tag) + + # Remove the angle brackets automatically added around the message ID by make_msgid. + stripped_msgid = email_message['Message-ID'].strip('<>') +@@ -55,7 +56,7 @@ class TestMailMover(unittest.TestCase): + write_notmuch_settings() + + # Create notmuch database +- Database().open(create=True).close() ++ notmuch2.Database.create().close() + + self.root = mailbox.Maildir(self.test_dir) + self.inbox = self.root.add_folder('inbox') +@@ -88,10 +89,12 @@ class TestMailMover(unittest.TestCase): + + @staticmethod + def get_folder_content(db, folder): +- return { +- (os.path.basename(msg.get_message_id()), msg.get_part(1).decode()) +- for msg in db.do_query('folder:{}'.format(folder)).search_messages() +- } ++ ret = set() ++ for msg in db.open().messages('folder:{}'.format(folder)): ++ with open(msg.path) as f: ++ ret.add((os.path.basename(msg.messageid), ++ email.message_from_file(f).get_payload())) ++ return ret + + def test_all_rule_cases(self): + from afew import MailMover +diff --git a/afew/utils.py b/afew/utils.py +index 8a9f491..04b41e9 100644 +--- a/afew/utils.py ++++ b/afew/utils.py +@@ -6,15 +6,21 @@ from datetime import datetime + + + def get_message_summary(message): +- when = datetime.fromtimestamp(float(message.get_date())) ++ when = datetime.fromtimestamp(float(message.date)) + sender = get_sender(message) +- subject = message.get_header('Subject') ++ try: ++ subject = message.header('Subject') ++ except LookupError: ++ subject = '' + return '[{date}] {sender} | {subject}'.format(date=when, sender=sender, + subject=subject) + + + def get_sender(message): +- sender = message.get_header('From') ++ try: ++ sender = message.header('From') ++ except LookupError: ++ sender = '' + name_match = re.search(r'(.+) <.+@.+\..+>', sender) + if name_match: + sender = name_match.group(1) +diff --git a/docs/extending.rst b/docs/extending.rst +index 844f9e6..1d6012a 100644 +--- a/docs/extending.rst ++++ b/docs/extending.rst +@@ -36,7 +36,7 @@ we ensure we don't bother looking at messages we've already looked at. + + The `handle_message()` method is the key one to implement. This will be called + for each message that matches the query. The argument is a `notmuch message object`_ +-and the key methods used by the afew filters are `get_header()`, `get_filename()` ++and the key methods used by the afew filters are `header()`, `filename()` + and `get_thread()`. + + .. _notmuch message object: http://pythonhosted.org/notmuch/#message-a-single-message +diff --git a/setup.py b/setup.py +index 2c4b558..78933e8 100755 +--- a/setup.py ++++ b/setup.py +@@ -8,7 +8,7 @@ from setuptools import setup, find_packages + + def get_requires(): + if os.environ.get('TRAVIS') != 'true' and os.environ.get('READTHEDOCS') != 'True': +- yield 'notmuch' ++ yield 'notmuch2' + yield 'chardet' + yield 'dkimpy' + diff -Nru afew-3.0.1/debian/patches/fix-invalid-escape-sequence.patch afew-3.0.1.post63/debian/patches/fix-invalid-escape-sequence.patch --- afew-3.0.1/debian/patches/fix-invalid-escape-sequence.patch 2025-02-15 20:18:08.000000000 -0400 +++ afew-3.0.1.post63/debian/patches/fix-invalid-escape-sequence.patch 1969-12-31 20:00:00.000000000 -0400 @@ -1,23 +0,0 @@ -From: Guillaume Seren <guillaumese...@gmail.com> -Date: Sun, 21 Aug 2022 14:00:36 +0200 -Subject: [PATCH] flake8: Fix W605 invalid escape sequence '\.' - -Origin: upstream, https://github.com/afewmail/afew/commit/86fc5091c7154862409cd201d1b83f26e8511a2c -Bug-Debian: https://bugs.debian.org/1085322 ---- - afew/files.py | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/afew/files.py b/afew/files.py -index 557e343..d3ae031 100644 ---- a/afew/files.py -+++ b/afew/files.py -@@ -22,7 +22,7 @@ class EventHandler(pyinotify.ProcessEvent): - self.database = database - super().__init__() - -- ignore_re = re.compile('(/xapian/.*(base.|tmp)$)|(\.lock$)|(/dovecot)') -+ ignore_re = re.compile(r'(/xapian/.*(base.|tmp)$)|(\.lock$)|(/dovecot)') - - def process_IN_DELETE(self, event): - if self.ignore_re.search(event.pathname): diff -Nru afew-3.0.1/debian/patches/fix-settings-file-reading-method-in-sett.patch afew-3.0.1.post63/debian/patches/fix-settings-file-reading-method-in-sett.patch --- afew-3.0.1/debian/patches/fix-settings-file-reading-method-in-sett.patch 1969-12-31 20:00:00.000000000 -0400 +++ afew-3.0.1.post63/debian/patches/fix-settings-file-reading-method-in-sett.patch 2025-03-15 11:07:48.000000000 -0300 @@ -0,0 +1,25 @@ +From: Gabriele Mongiano <hom3rs...@gmail.com> +Date: Fri, 8 Nov 2024 15:22:15 +0100 +Subject: Fix settings file reading method in Settings.py + +- Updated method from `readfp` to `read_file` for compatibility with Python 3. +- No change in functionality; improves code compliance with current Python standards. + +(this is upstream PR 350) +--- + afew/Settings.py | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/afew/Settings.py b/afew/Settings.py +index 44d6f3c..86a543b 100644 +--- a/afew/Settings.py ++++ b/afew/Settings.py +@@ -18,7 +18,7 @@ settings = ConfigParser() + # preserve the capitalization of the keys. + settings.optionxform = str + +-settings.readfp(open(os.path.join(os.path.dirname(__file__), 'defaults', 'afew.config'))) ++settings.read_file(open(os.path.join(os.path.dirname(__file__), 'defaults', 'afew.config'))) + settings.read(os.path.join(user_config_dir, 'config')) + + # All the values for keys listed here are interpreted as ;-delimited lists diff -Nru afew-3.0.1/debian/patches/series afew-3.0.1.post63/debian/patches/series --- afew-3.0.1/debian/patches/series 2025-02-15 20:18:08.000000000 -0400 +++ afew-3.0.1.post63/debian/patches/series 2025-03-15 11:07:48.000000000 -0300 @@ -1 +1,2 @@ -fix-invalid-escape-sequence.patch +apply-upstream-pull-request-320.patch +fix-settings-file-reading-method-in-sett.patch diff -Nru afew-3.0.1/docs/conf.py afew-3.0.1.post63/docs/conf.py --- afew-3.0.1/docs/conf.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/docs/conf.py 2025-03-15 10:46:03.000000000 -0300 @@ -12,7 +12,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import sys +import os from pkg_resources import get_distribution # If extensions (or modules to document with autodoc) are in another directory, @@ -42,7 +43,7 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -79,37 +80,37 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- @@ -121,26 +122,26 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # "<project> v<release> documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -149,44 +150,44 @@ # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a <link> tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'afewdoc' @@ -195,10 +196,10 @@ # -- Options for LaTeX output -------------------------------------------------- # The paper size ('letter' or 'a4'). -#latex_paper_size = 'letter' +# latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). -#latex_font_size = '10pt' +# latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). @@ -209,26 +210,26 @@ # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Additional stuff for the LaTeX preamble. -#latex_preamble = '' +# latex_preamble = '' # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- diff -Nru afew-3.0.1/docs/filters.rst afew-3.0.1.post63/docs/filters.rst --- afew-3.0.1/docs/filters.rst 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/docs/filters.rst 2025-03-15 10:46:03.000000000 -0300 @@ -26,7 +26,7 @@ DKIMValidityFilter ------------------ -This filter verifies DKIM signatures of E-Mails with DKIM header, and adds `dkin-ok` or `dkin-fail` tags. +This filter verifies DKIM signatures of E-Mails with DKIM header, and adds `dkim-ok` or `dkim-fail` tags. DMARCReportInspectionFilter --------------------------- @@ -145,7 +145,7 @@ [HeaderMatchingFilter.3] header = X-Redmine-Project - pattern = (?P<project>.*) + pattern = (?P<project>.+) tags = +redmine;+{project} SpamFilter and ListMailsFilter are implemented using HeaderMatchingFilter, and are diff -Nru afew-3.0.1/.github/workflows/build.yml afew-3.0.1.post63/.github/workflows/build.yml --- afew-3.0.1/.github/workflows/build.yml 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/.github/workflows/build.yml 2025-03-15 10:46:03.000000000 -0300 @@ -7,24 +7,32 @@ build-ubuntu: strategy: matrix: - python: [3.6, 3.7, 3.8] - name: Build (Python ${{ matrix.python }}) - runs-on: ubuntu-18.04 + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + name: Build (Python ${{ matrix.python-version }}) + runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 - - name: Set up Python + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: - python-version: '${{ matrix.python }}' + python-version: '${{ matrix.python-version }}' + - name: Display Python version + run: python -c "import sys; print(sys.version)" - name: Install dependencies shell: bash run: | sudo apt-get update - sudo apt-get install -y notmuch python3-notmuch python3-venv + sudo apt-get install -y notmuch python3-notmuch python3-venv flake8 python3 -m venv env source ./env/bin/activate - pip install setuptools pytest dkimpy + pip install setuptools setuptools_scm pytest dkimpy ln -s /usr/lib/python3/dist-packages/notmuch ./env/lib/python*/site-packages + - name: flake8 lint + run: | + source ./env/bin/activate + flake8 --ignore=E501,W504 afew/ - name: Tests run: | source ./env/bin/activate @@ -38,20 +46,9 @@ run: | source ./env/bin/activate python setup.py install - - name: Generate coverage report - run: | - source ./env/bin/activate - pip install pytest-cov - pytest --cov=./ --cov-report=xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml - flags: unittests - name: codecov-umbrella - fail_ci_if_error: true - name: Docs run: | source ./env/bin/activate pip install sphinx - python setup.py build_sphinx -b html,man + sphinx-build -b html docs $(mktemp -d) + sphinx-build -b man docs $(mktemp -d) diff -Nru afew-3.0.1/.github/workflows/release.yml afew-3.0.1.post63/.github/workflows/release.yml --- afew-3.0.1/.github/workflows/release.yml 1969-12-31 20:00:00.000000000 -0400 +++ afew-3.0.1.post63/.github/workflows/release.yml 2025-03-15 10:46:03.000000000 -0300 @@ -0,0 +1,31 @@ +name: Release + +on: + release: + types: + - published + +jobs: + release: + needs: [build-ubuntu] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools setuptools_scm wheel twine + - name: Build and publish (testpypi) + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.testpypi_token }} + TWINE_REPOSITORY: testpypi + run: | + python setup.py sdist + twine upload dist/* diff -Nru afew-3.0.1/NEWS.md afew-3.0.1.post63/NEWS.md --- afew-3.0.1/NEWS.md 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/NEWS.md 2025-03-15 10:46:03.000000000 -0300 @@ -1,3 +1,26 @@ +afew 3.1.0 (unreleased) +======================= + +Python 3.6 support dropped + + afew stopped supporting the older python version 3.6. + +Handle DMARC report with empty spf or dkim XML nodes + +DMARC Filter: allow to define subject regexp + + Some DMARC report mail have a prefix before "Dmarc Report" in the subject + and where not checked by the plugin. + Afew now allows the user to define the suject regexp in the config file. + +Get notmuch database path using Database wrapper + + This allows FolderNameFilter to work with a relative path in database.path of notmuch config file. + +HeaderMatchingFilter: do not convert user supplied tags + + This prevents afew to lowercase the tags defined by the user, allowing to have non lowercase tags. + afew 3.0.0 (2020-03-10) ======================= diff -Nru afew-3.0.1/PKG-INFO afew-3.0.1.post63/PKG-INFO --- afew-3.0.1/PKG-INFO 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/PKG-INFO 1969-12-31 20:00:00.000000000 -0400 @@ -1,20 +0,0 @@ -Metadata-Version: 1.1 -Name: afew -Version: 3.0.1 -Summary: An initial tagging script for notmuch mail -Home-page: https://github.com/afewmail/afew -Author: UNKNOWN -Author-email: UNKNOWN -License: ISC -Description: UNKNOWN -Platform: UNKNOWN -Classifier: License :: OSI Approved :: ISC License (ISCL) -Classifier: Development Status :: 4 - Beta -Classifier: Environment :: Console -Classifier: Intended Audience :: End Users/Desktop -Classifier: Programming Language :: Python -Classifier: Topic :: Communications :: Email -Classifier: Topic :: Communications :: Email :: Filters -Classifier: Topic :: Utilities -Classifier: Topic :: Database -Provides: afew diff -Nru afew-3.0.1/README.rst afew-3.0.1.post63/README.rst --- afew-3.0.1/README.rst 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/README.rst 2025-03-15 10:46:03.000000000 -0300 @@ -2,7 +2,7 @@ afew ==== -|GithubTag| |CodeCov| |CI Status| +|GithubTag| |CI Status| About ----- @@ -23,7 +23,7 @@ configurable rules that can contain arbitrary notmuch queries to match against any searchable attributes. -fyi: afew plays nicely with alot, a GUI for notmuch mail ;) +fyi: afew plays nicely with alot, a TUI for notmuch mail ;) * https://github.com/pazz/alot @@ -32,9 +32,9 @@ IRC --- -Feel free to ask your questions and discuss usage in the `#afewmail IRC Channel`_ on freenode. +Feel free to ask your questions and discuss usage in the `#afewmail IRC Channel`_ on Libera.Chat. -.. _#afewmail IRC Channel: http://webchat.freenode.net/?channels=#afewmail +.. _#afewmail IRC Channel: http://web.libera.chat/?channels=#afewmail Features @@ -51,7 +51,6 @@ * can move mails based on arbitrary notmuch queries, so your sorting may show on your traditional mail client (well, almost ;)) * has a ``--dry-run`` mode for safe testing -* works with python 3.6+ @@ -69,7 +68,5 @@ .. |GithubTag| image:: https://img.shields.io/github/tag/afewmail/afew.svg :target: https://github.com/afewmail/afew/releases -.. |CodeCov| image:: https://codecov.io/gh/afewmail/afew/branch/master/graph/badge.svg - :target: https://codecov.io/gh/afewmail/afew .. |CI Status| image:: https://github.com/afewmail/afew/workflows/CI/badge.svg :target: https://github.com/afewmail/afew/actions diff -Nru afew-3.0.1/.readthedocs.yaml afew-3.0.1.post63/.readthedocs.yaml --- afew-3.0.1/.readthedocs.yaml 1969-12-31 20:00:00.000000000 -0400 +++ afew-3.0.1.post63/.readthedocs.yaml 2025-03-15 10:46:03.000000000 -0300 @@ -0,0 +1,22 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# We recommend specifying your dependencies to enable reproducible builds: +# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt diff -Nru afew-3.0.1/setup.cfg afew-3.0.1.post63/setup.cfg --- afew-3.0.1/setup.cfg 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/setup.cfg 1969-12-31 20:00:00.000000000 -0400 @@ -1,4 +0,0 @@ -[egg_info] -tag_build = -tag_date = 0 - diff -Nru afew-3.0.1/setup.py afew-3.0.1.post63/setup.py --- afew-3.0.1/setup.py 2021-01-14 04:42:55.000000000 -0400 +++ afew-3.0.1.post63/setup.py 2025-03-15 10:46:03.000000000 -0300 @@ -12,6 +12,8 @@ yield 'chardet' yield 'dkimpy' +with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'README.rst'), encoding='utf-8') as f: + long_description = f.read() setup( name='afew', @@ -19,6 +21,8 @@ description="An initial tagging script for notmuch mail", url="https://github.com/afewmail/afew", license="ISC", + long_description=long_description, + long_description_content_type="text/x-rst", setup_requires=['setuptools_scm'], packages=find_packages(), test_suite='afew.tests',