diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/chains/tests/test_headers.py | 27 | ||||
| -rw-r--r-- | src/mailman/config/configure.zcml | 6 | ||||
| -rw-r--r-- | src/mailman/interfaces/mailinglist.py | 31 | ||||
| -rw-r--r-- | src/mailman/model/mailinglist.py | 50 | ||||
| -rw-r--r-- | src/mailman/model/tests/test_mailinglist.py | 46 | ||||
| -rw-r--r-- | src/mailman/rules/docs/header-matching.rst | 12 | ||||
| -rw-r--r-- | src/mailman/utilities/importer.py | 12 | ||||
| -rw-r--r-- | src/mailman/utilities/tests/test_import.py | 25 |
8 files changed, 174 insertions, 35 deletions
diff --git a/src/mailman/chains/tests/test_headers.py b/src/mailman/chains/tests/test_headers.py index 636eec196..851720f95 100644 --- a/src/mailman/chains/tests/test_headers.py +++ b/src/mailman/chains/tests/test_headers.py @@ -30,7 +30,7 @@ from mailman.config import config from mailman.core.chains import process from mailman.email.message import Message from mailman.interfaces.chain import LinkAction, HoldEvent -from mailman.model.mailinglist import HeaderMatch +from mailman.interfaces.mailinglist import IHeaderMatchSet from mailman.testing.layers import ConfigLayer from mailman.testing.helpers import ( configuration, event_subscribers, get_queue_messages, LogFileMark, @@ -128,32 +128,32 @@ class TestHeaderChain(unittest.TestCase): # Test that the header-match chain has the header checks from the # mailing-list configuration. chain = config.chains['header-match'] - self._mlist.header_matches = [HeaderMatch(header='Foo', pattern='a+')] + header_matches = IHeaderMatchSet(self._mlist) + header_matches.add('Foo', 'a+', None) links = [link for link in chain.get_links(self._mlist, Message(), {}) if link.rule.name != 'any'] self.assertEqual(len(links), 1) self.assertEqual(links[0].action, LinkAction.defer) - self.assertEqual(links[0].rule.header, 'Foo') + self.assertEqual(links[0].rule.header, 'foo') self.assertEqual(links[0].rule.pattern, 'a+') def test_list_complex_rule(self): # Test that the mailing-list header-match complex rules are read # properly. chain = config.chains['header-match'] - self._mlist.header_matches = [ - HeaderMatch(header='Foo', pattern='a+', chain='reject'), - HeaderMatch(header='Bar', pattern='b+', chain='discard'), - HeaderMatch(header='Baz', pattern='z+', chain='accept'), - ] + header_matches = IHeaderMatchSet(self._mlist) + header_matches.add('Foo', 'a+', 'reject') + header_matches.add('Bar', 'b+', 'discard') + header_matches.add('Baz', 'z+', 'accept') links = [link for link in chain.get_links(self._mlist, Message(), {}) if link.rule.name != 'any'] self.assertEqual(len(links), 3) self.assertListEqual( [(link.rule.header, link.rule.pattern, link.action, link.chain.name) for link in links], - [('Foo', 'a+', LinkAction.jump, 'reject'), - ('Bar', 'b+', LinkAction.jump, 'discard'), - ('Baz', 'z+', LinkAction.jump, 'accept'), + [('foo', 'a+', LinkAction.jump, 'reject'), + ('bar', 'b+', LinkAction.jump, 'discard'), + ('baz', 'z+', LinkAction.jump, 'accept'), ]) @configuration('antispam', header_checks=""" @@ -173,9 +173,8 @@ MIME-Version: 1.0 A message body. """) msgdata = {} - self._mlist.header_matches = [ - HeaderMatch(header='Foo', pattern='foo', chain='accept') - ] + header_matches = IHeaderMatchSet(self._mlist) + header_matches.add('Foo', 'foo', 'accept') # This event subscriber records the event that occurs when the message # is processed by the owner chain. events = [] diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml index 632771d42..a8fa2c119 100644 --- a/src/mailman/config/configure.zcml +++ b/src/mailman/config/configure.zcml @@ -36,6 +36,12 @@ <adapter for="mailman.interfaces.mailinglist.IMailingList" + provides="mailman.interfaces.mailinglist.IHeaderMatchSet" + factory="mailman.model.mailinglist.HeaderMatchSet" + /> + + <adapter + for="mailman.interfaces.mailinglist.IMailingList" provides="mailman.interfaces.requests.IListRequests" factory="mailman.model.requests.ListRequests" /> diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py index 59cb0ffd4..7f3b68008 100644 --- a/src/mailman/interfaces/mailinglist.py +++ b/src/mailman/interfaces/mailinglist.py @@ -860,3 +860,34 @@ class IHeaderMatch(Interface): If it is None, the `[antispam]jump_chain` action in the configuration file is used. """) + + +class IHeaderMatchSet(Interface): + """The set of header matching rules for a mailing list.""" + + def clear(): + """Clear the set of header matching rules.""" + + def add(header, pattern, chain): + """Add the given header matching rule to this mailinglist's set. + + :param header: The email header to filter on. It will be converted to + lowercase for easier removal. + :type header: string + :param pattern: The regular expression to use. + :type pattern: string + :param chain: The chain to jump to, or None to use the site-wide + configuration. Defaults to None. + :type chain: string or None + :raises ValueError: there can be only one couple of header and pattern + for a mailinglist. + """ + + def remove(header, pattern): + """Remove the given header matching rule from this mailinglist's set. + + :param header: The email header part of the rule to be removed. + :type header: string + :param pattern: The regular expression part of the rule to be removed. + :type pattern: string + """ diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py index f7b1cf72c..617109a7e 100644 --- a/src/mailman/model/mailinglist.py +++ b/src/mailman/model/mailinglist.py @@ -38,8 +38,8 @@ from mailman.interfaces.domain import IDomainManager from mailman.interfaces.languages import ILanguageManager from mailman.interfaces.mailinglist import ( IAcceptableAlias, IAcceptableAliasSet, IListArchiver, IListArchiverSet, - IHeaderMatch, IMailingList, Personalization, ReplyToMunging, - SubscriptionPolicy) + IHeaderMatch, IHeaderMatchSet, IMailingList, Personalization, + ReplyToMunging, SubscriptionPolicy) from mailman.interfaces.member import ( AlreadySubscribedError, MemberRole, MissingPreferredAddressError, SubscriptionEvent) @@ -58,6 +58,7 @@ from sqlalchemy import ( LargeBinary, PickleType, Unicode) from sqlalchemy.event import listen from sqlalchemy.orm import relationship +from sqlalchemy.orm.exc import NoResultFound from urllib.parse import urljoin from zope.component import getUtility from zope.event import notify @@ -638,3 +639,48 @@ class HeaderMatch(Model): header = Column(Unicode) pattern = Column(Unicode) chain = Column(Unicode, nullable=True) + + + +@implementer(IHeaderMatchSet) +class HeaderMatchSet: + """See `IHeaderMatchSet`.""" + + def __init__(self, mailing_list): + self._mailing_list = mailing_list + + @dbconnection + def clear(self, store): + """See `IHeaderMatchSet`.""" + store.query(HeaderMatch).filter( + HeaderMatch.mailing_list == self._mailing_list).delete() + + @dbconnection + def add(self, store, header, pattern, chain=None): + header = header.lower() + existing = store.query(HeaderMatch).filter( + HeaderMatch.mailing_list == self._mailing_list, + HeaderMatch.header == header, + HeaderMatch.pattern == pattern).count() + if existing > 0: + raise ValueError('Pattern already exists') + header_match = HeaderMatch( + mailing_list=self._mailing_list, + header=header, pattern=pattern, chain=chain) + store.add(header_match) + + @dbconnection + def remove(self, store, header, pattern): + header = header.lower() + # Don't just filter and use delete(), or the MailingList.header_matches + # collection will not be updated: + # http://docs.sqlalchemy.org/en/rel_1_0/orm/collections.html#dynamic-relationship-loaders + try: + existing = store.query(HeaderMatch).filter( + HeaderMatch.mailing_list == self._mailing_list, + HeaderMatch.header == header, + HeaderMatch.pattern == pattern).one() + except NoResultFound: + raise ValueError('Pattern does not exist') + else: + self._mailing_list.header_matches.remove(existing) diff --git a/src/mailman/model/tests/test_mailinglist.py b/src/mailman/model/tests/test_mailinglist.py index 745096b4b..c497cb474 100644 --- a/src/mailman/model/tests/test_mailinglist.py +++ b/src/mailman/model/tests/test_mailinglist.py @@ -32,7 +32,7 @@ from mailman.config import config from mailman.database.transaction import transaction from mailman.interfaces.listmanager import IListManager from mailman.interfaces.mailinglist import ( - IAcceptableAliasSet, IListArchiverSet) + IAcceptableAliasSet, IHeaderMatchSet, IListArchiverSet) from mailman.interfaces.member import ( AlreadySubscribedError, MemberRole, MissingPreferredAddressError) from mailman.interfaces.usermanager import IUserManager @@ -163,3 +163,47 @@ class TestAcceptableAliases(unittest.TestCase): self.assertEqual(['bee@example.com'], list(alias_set.aliases)) getUtility(IListManager).delete(self._mlist) self.assertEqual(len(list(alias_set.aliases)), 0) + + + +class TestHeaderMatch(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('ant@example.com') + + def test_lowercase_header(self): + with transaction(): + header_matches = IHeaderMatchSet(self._mlist) + header_matches.add('Header', 'pattern') + self.assertEqual(len(self._mlist.header_matches), 1) + self.assertEqual(self._mlist.header_matches[0].header, 'header') + + def test_chain_defaults_to_none(self): + with transaction(): + header_matches = IHeaderMatchSet(self._mlist) + header_matches.add('header', 'pattern') + self.assertEqual(len(self._mlist.header_matches), 1) + self.assertEqual(self._mlist.header_matches[0].chain, None) + + def test_duplicate(self): + with transaction(): + header_matches = IHeaderMatchSet(self._mlist) + header_matches.add('Header', 'pattern') + self.assertRaises(ValueError, + header_matches.add, 'Header', 'pattern') + self.assertEqual(len(self._mlist.header_matches), 1) + + def test_remove_non_existent(self): + with transaction(): + header_matches = IHeaderMatchSet(self._mlist) + self.assertRaises(ValueError, + header_matches.remove, 'header', 'pattern') + + def test_add_remove(self): + with transaction(): + header_matches = IHeaderMatchSet(self._mlist) + header_matches.add('header', 'pattern') + self.assertEqual(len(self._mlist.header_matches), 1) + header_matches.remove('header', 'pattern') + self.assertEqual(len(self._mlist.header_matches), 0) diff --git a/src/mailman/rules/docs/header-matching.rst b/src/mailman/rules/docs/header-matching.rst index c552ea521..05d01efb2 100644 --- a/src/mailman/rules/docs/header-matching.rst +++ b/src/mailman/rules/docs/header-matching.rst @@ -131,10 +131,9 @@ action. The list administrator wants to match not on four stars, but on three plus signs, but only for the current mailing list. - >>> from mailman.model.mailinglist import HeaderMatch - >>> mlist.header_matches = [ - ... HeaderMatch(header='x-spam-score', pattern='[+]{3,}') - ... ] + >>> from mailman.interfaces.mailinglist import IHeaderMatchSet + >>> header_matches = IHeaderMatchSet(mlist) + >>> header_matches.add('x-spam-score', '[+]{3,}') A message with a spam score of two pluses does not match. @@ -178,9 +177,8 @@ As does a message with a spam score of four pluses. Now, the list administrator wants to match on three plus signs, but wants those emails to be discarded instead of held. - >>> mlist.header_matches = [ - ... HeaderMatch(header='x-spam-score', pattern='[+]{3,}', chain='discard') - ... ] + >>> header_matches.remove('x-spam-score', '[+]{3,}') + >>> header_matches.add('x-spam-score', '[+]{3,}', 'discard') A message with a spam score of three pluses will still match, and the message will be discarded. diff --git a/src/mailman/utilities/importer.py b/src/mailman/utilities/importer.py index f767ddbe3..4e3eab6cf 100644 --- a/src/mailman/utilities/importer.py +++ b/src/mailman/utilities/importer.py @@ -42,13 +42,12 @@ from mailman.interfaces.bounce import UnrecognizedBounceDisposition from mailman.interfaces.chain import LinkAction from mailman.interfaces.digests import DigestFrequency from mailman.interfaces.languages import ILanguageManager -from mailman.interfaces.mailinglist import IAcceptableAliasSet +from mailman.interfaces.mailinglist import IAcceptableAliasSet, IHeaderMatchSet from mailman.interfaces.mailinglist import Personalization, ReplyToMunging from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.member import DeliveryMode, DeliveryStatus, MemberRole from mailman.interfaces.nntp import NewsgroupModeration from mailman.interfaces.usermanager import IUserManager -from mailman.model.mailinglist import HeaderMatch from mailman.utilities.filesystem import makedirs from mailman.utilities.i18n import search from sqlalchemy import Boolean @@ -335,6 +334,7 @@ def import_config_pck(mlist, config_dict): # expression. Make that explicit for MM3. alias_set.add('^' + address) # Handle header_filter_rules conversion to header_matches + header_match_set = IHeaderMatchSet(mlist) header_filter_rules = config_dict.get('header_filter_rules', []) for line_patterns, action, _unused in header_filter_rules: try: @@ -372,8 +372,12 @@ def import_config_pck(mlist, config_dict): log.warning('Skipping header_filter rule because of an ' 'invalid regular expression: %r', line_pattern) continue - mlist.header_matches.append(HeaderMatch( - header=header, pattern=pattern, chain=chain)) + try: + header_match_set.add(header, pattern, chain) + except ValueError: + log.warning('Skipping duplicate header_filter rule: %r', + line_pattern) + continue # Handle conversion to URIs. In MM2.1, the decorations are strings # containing placeholders, and there's no provision for language-specific # templates. In MM3, template locations are specified by URLs with the diff --git a/src/mailman/utilities/tests/test_import.py b/src/mailman/utilities/tests/test_import.py index 85ee993ba..64f8e061f 100644 --- a/src/mailman/utilities/tests/test_import.py +++ b/src/mailman/utilities/tests/test_import.py @@ -44,12 +44,11 @@ from mailman.interfaces.bans import IBanManager from mailman.interfaces.bounce import UnrecognizedBounceDisposition from mailman.interfaces.languages import ILanguageManager from mailman.interfaces.mailinglist import ( - IAcceptableAliasSet, SubscriptionPolicy) + IAcceptableAliasSet, IHeaderMatchSet, SubscriptionPolicy) from mailman.interfaces.member import DeliveryMode, DeliveryStatus from mailman.interfaces.nntp import NewsgroupModeration from mailman.interfaces.templates import ITemplateLoader from mailman.interfaces.usermanager import IUserManager -from mailman.model.mailinglist import HeaderMatch from mailman.testing.helpers import LogFileMark from mailman.testing.layers import ConfigLayer from mailman.utilities.filesystem import makedirs @@ -335,10 +334,8 @@ class TestBasicImport(unittest.TestCase): def test_header_matches(self): # This test contail real cases of header_filter_rules self._pckdict['header_filter_rules'] = [ - ('^X-Spam-Status: Yes', 3, False), - ('X-Spam-Status: Yes', 3, False), ('X\\-Spam\\-Status\\: Yes.*', 3, False), - ('X-Spam-Status: Yes\r\n\r\n', 2, False), + ('^X-Spam-Status: Yes\r\n\r\n', 2, False), ('^X-Spam-Level: \\*\\*\\*.*$', 3, False), ('^X-Spam-Level:.\\*\\*\r\n^X-Spam:.\\Yes', 3, False), ('Subject: \\[SPAM\\].*', 3, False), @@ -368,8 +365,6 @@ class TestBasicImport(unittest.TestCase): self.assertListEqual( [ (hm.header, hm.pattern, hm.chain) for hm in self._mlist.header_matches ], [ - ('x-spam-status', 'Yes', 'discard'), - ('x-spam-status', 'Yes', 'discard'), ('x-spam-status', 'Yes.*', 'discard'), ('x-spam-status', 'Yes', 'reject'), ('x-spam-level', '\\*\\*\\*.*$', 'discard'), @@ -461,6 +456,22 @@ class TestBasicImport(unittest.TestCase): for member in self._mlist.owners.members: member.unsubscribe() + def test_header_matches_duplicate(self): + # Check that duplicate patterns don't cause tracebacks + self._pckdict['header_filter_rules'] = [ + ('SomeHeaderName: test-pattern', 3, False), + ('SomeHeaderName: test-pattern', 2, False), + ] + error_log = LogFileMark('mailman.error') + self._import() + self.assertListEqual( + [ (hm.header, hm.pattern, hm.chain) + for hm in self._mlist.header_matches ], + [ ('someheadername', 'test-pattern', 'discard') ] + ) + self.assertIn('Skipping duplicate header_filter rule', + error_log.readline()) + class TestArchiveImport(unittest.TestCase): |
