diff options
| author | Barry Warsaw | 2012-04-06 16:31:14 -0600 |
|---|---|---|
| committer | Barry Warsaw | 2012-04-06 16:31:14 -0600 |
| commit | dc2b2e2ba161c120fed4ab06d61d4b2c9d782869 (patch) | |
| tree | d00ee390b5b7b493abcbc0f6c7cbadad6b1bbb08 /src | |
| parent | 180d4968c277b533507db04bb9d363c6a65a2af5 (diff) | |
| download | mailman-dc2b2e2ba161c120fed4ab06d61d4b2c9d782869.tar.gz mailman-dc2b2e2ba161c120fed4ab06d61d4b2c9d782869.tar.zst mailman-dc2b2e2ba161c120fed4ab06d61d4b2c9d782869.zip | |
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/app/bounces.py | 3 | ||||
| -rw-r--r-- | src/mailman/app/tests/test_bounces.py | 65 | ||||
| -rw-r--r-- | src/mailman/chains/headers.py | 114 | ||||
| -rw-r--r-- | src/mailman/chains/tests/test_headers.py | 126 | ||||
| -rw-r--r-- | src/mailman/config/config.py | 10 | ||||
| -rw-r--r-- | src/mailman/config/schema.cfg | 29 | ||||
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 9 | ||||
| -rw-r--r-- | src/mailman/interfaces/chain.py | 8 | ||||
| -rw-r--r-- | src/mailman/rules/any.py | 2 | ||||
| -rw-r--r-- | src/mailman/rules/docs/header-matching.rst | 189 | ||||
| -rw-r--r-- | src/mailman/testing/helpers.py | 2 |
11 files changed, 364 insertions, 193 deletions
diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py index 34d90ac82..4107a8c6e 100644 --- a/src/mailman/app/bounces.py +++ b/src/mailman/app/bounces.py @@ -108,9 +108,6 @@ class _BaseVERPParser: self._pattern = pattern self._cre = re.compile(pattern, re.IGNORECASE) - def _get_addresses(self, match_object): - raise NotImplementedError - def get_verp(self, mlist, msg): """Extract a set of VERP bounce addresses. diff --git a/src/mailman/app/tests/test_bounces.py b/src/mailman/app/tests/test_bounces.py index d0d94df5e..76cabf329 100644 --- a/src/mailman/app/tests/test_bounces.py +++ b/src/mailman/app/tests/test_bounces.py @@ -21,6 +21,7 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ + 'TestBounceMessage', 'TestMaybeForward', 'TestProbe', 'TestSendProbe', @@ -38,7 +39,7 @@ import unittest from zope.component import getUtility from mailman.app.bounces import ( - ProbeVERP, StandardVERP, maybe_forward, send_probe) + ProbeVERP, StandardVERP, bounce_message, maybe_forward, send_probe) from mailman.app.lifecycle import create_list from mailman.app.membership import add_member from mailman.config import config @@ -50,7 +51,7 @@ from mailman.interfaces.usermanager import IUserManager from mailman.testing.helpers import ( LogFileMark, get_queue_messages, - specialized_message_from_string as message_from_string) + specialized_message_from_string as mfs) from mailman.testing.layers import ConfigLayer @@ -66,7 +67,7 @@ class TestVERP(unittest.TestCase): def test_no_verp(self): # The empty set is returned when there is no VERP headers. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: mailman-bounces@example.com @@ -75,7 +76,7 @@ To: mailman-bounces@example.com def test_verp_in_to(self): # A VERP address is found in the To header. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: test-bounces+anne=example.org@example.com @@ -85,7 +86,7 @@ To: test-bounces+anne=example.org@example.com def test_verp_in_delivered_to(self): # A VERP address is found in the Delivered-To header. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com Delivered-To: test-bounces+anne=example.org@example.com @@ -95,7 +96,7 @@ Delivered-To: test-bounces+anne=example.org@example.com def test_verp_in_envelope_to(self): # A VERP address is found in the Envelope-To header. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com Envelope-To: test-bounces+anne=example.org@example.com @@ -105,7 +106,7 @@ Envelope-To: test-bounces+anne=example.org@example.com def test_verp_in_apparently_to(self): # A VERP address is found in the Apparently-To header. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com Apparently-To: test-bounces+anne=example.org@example.com @@ -115,7 +116,7 @@ Apparently-To: test-bounces+anne=example.org@example.com def test_verp_with_empty_header(self): # A VERP address is found, but there's an empty header. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: test-bounces+anne=example.org@example.com To: @@ -126,7 +127,7 @@ To: def test_no_verp_with_empty_header(self): # There's an empty header, and no VERP address is found. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: @@ -135,7 +136,7 @@ To: def test_verp_with_non_match(self): # A VERP address is found, but a header had a non-matching pattern. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: test-bounces+anne=example.org@example.com To: test-bounces@example.com @@ -146,7 +147,7 @@ To: test-bounces@example.com def test_no_verp_with_non_match(self): # No VERP address is found, and a header had a non-matching pattern. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: test-bounces@example.com @@ -155,7 +156,7 @@ To: test-bounces@example.com def test_multiple_verps(self): # More than one VERP address was found in the same header. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: test-bounces+anne=example.org@example.com To: test-bounces+anne=example.org@example.com @@ -167,7 +168,7 @@ To: test-bounces+anne=example.org@example.com def test_multiple_verps_different_values(self): # More than one VERP address was found in the same header with # different values. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: test-bounces+anne=example.org@example.com To: test-bounces+bart=example.org@example.com @@ -179,7 +180,7 @@ To: test-bounces+bart=example.org@example.com def test_multiple_verps_different_values_different_headers(self): # More than one VERP address was found in different headers with # different values. - msg = message_from_string("""\ + msg = mfs("""\ From: postmaster@example.com To: test-bounces+anne=example.org@example.com Apparently-To: test-bounces+bart=example.org@example.com @@ -200,7 +201,7 @@ class TestSendProbe(unittest.TestCase): self._member = add_member(self._mlist, 'anne@example.com', 'Anne Person', 'xxx', DeliveryMode.regular, 'en') - self._msg = message_from_string("""\ + self._msg = mfs("""\ From: bouncer@example.com To: anne@example.com Subject: You bounced @@ -292,7 +293,7 @@ class TestSendProbeNonEnglish(unittest.TestCase): self._member = add_member(self._mlist, 'anne@example.com', 'Anne Person', 'xxx', DeliveryMode.regular, 'en') - self._msg = message_from_string("""\ + self._msg = mfs("""\ From: bouncer@example.com To: anne@example.com Subject: You bounced @@ -358,7 +359,7 @@ class TestProbe(unittest.TestCase): self._member = add_member(self._mlist, 'anne@example.com', 'Anne Person', 'xxx', DeliveryMode.regular, 'en') - self._msg = message_from_string("""\ + self._msg = mfs("""\ From: bouncer@example.com To: anne@example.com Subject: You bounced @@ -372,7 +373,7 @@ Message-ID: <first> token = send_probe(self._member, self._msg) # Simulate a bounce of the message in the virgin queue. message = get_queue_messages('virgin')[0].msg - bounce = message_from_string("""\ + bounce = mfs("""\ To: {0} From: mail-daemon@example.com @@ -395,7 +396,7 @@ class TestMaybeForward(unittest.TestCase): site_owner: postmaster@example.com """) self._mlist = create_list('test@example.com') - self._msg = message_from_string("""\ + self._msg = mfs("""\ From: bouncer@example.com To: test-bounces@example.com Subject: You bounced @@ -511,3 +512,29 @@ Message-ID: <first> # recipients of this message. self.assertEqual(items[0].msgdata['recipients'], set(['postmaster@example.com',])) + + + +class TestBounceMessage(unittest.TestCase): + """Test the `mailman.app.bounces.bounce_message()` function.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + self._msg = mfs("""\ +From: anne@example.com +To: test@example.com +Subject: Ignore + +""") + + def test_no_sender(self): + # The message won't be bounced if it has no discernible sender. + self._msg.sender = None + bounce_message(self._mlist, self._msg) + items = get_queue_messages('virgin') + # Nothing in the virgin queue means nothing's been bounced. + self.assertEqual(items, []) + + diff --git a/src/mailman/chains/headers.py b/src/mailman/chains/headers.py index 335c0a156..d9f8356f8 100644 --- a/src/mailman/chains/headers.py +++ b/src/mailman/chains/headers.py @@ -27,41 +27,36 @@ __all__ = [ import re import logging -import itertools from zope.interface import implements from mailman.chains.base import Chain, Link from mailman.config import config from mailman.core.i18n import _ -from mailman.interfaces.chain import IChainIterator, LinkAction +from mailman.interfaces.chain import LinkAction from mailman.interfaces.rules import IRule -log = logging.getLogger('mailman.vette') +log = logging.getLogger('mailman.error') -def make_link(entry): +def make_link(header, pattern): """Create a Link object. - :param entry: a 2- or 3-tuple describing a link. If a 2-tuple, it is a - header and a pattern, and a default chain of 'hold' will be used. If - a 3-tuple, the third item is the chain name to use. - :return: an ILink. + The link action is always to defer, since at the end of all the header + checks, we'll jump to the chain defined in the configuration file, should + any of them have matched. + + :param header: The email header name to check, e.g. X-Spam. + :type header: string + :param pattern: A regular expression for matching the header value. + :type pattern: string + :return: The link representing this rule check. + :rtype: `ILink` """ - if len(entry) == 2: - header, pattern = entry - chain_name = 'hold' - elif len(entry) == 3: - header, pattern, chain_name = entry - # We don't assert that the chain exists here because the jump - # chain may not yet have been created. - else: - raise AssertionError('Bad link description: {0}'.format(entry)) rule = HeaderMatchRule(header, pattern) - chain = config.chains[chain_name] - return Link(rule, LinkAction.jump, chain) + return Link(rule, LinkAction.defer) @@ -73,8 +68,8 @@ class HeaderMatchRule: _count = 1 def __init__(self, header, pattern): - self._header = header - self._pattern = pattern + self.header = header + self.pattern = pattern self.name = 'header-match-{0:02}'.format(HeaderMatchRule._count) HeaderMatchRule._count += 1 self.description = '{0}: {1}'.format(header, pattern) @@ -85,11 +80,16 @@ class HeaderMatchRule: # rule name. I suppose we could do the better hit recording in the # check() method, and set self.record = False. self.record = True + # Register this rule so that other parts of the system can query it. + assert self.name not in config.rules, ( + 'Duplicate HeaderMatchRule: {0} [{1}: {2}]'.format( + self.name, self.header, self.pattern)) + config.rules[self.name] = self def check(self, mlist, msg, msgdata): """See `IRule`.""" - for value in msg.get_all(self._header, []): - if re.search(self._pattern, value, re.IGNORECASE): + for value in msg.get_all(self.header, []): + if re.search(self.pattern, value, re.IGNORECASE): return True return False @@ -104,53 +104,49 @@ class HeaderMatchChain(Chain): def __init__(self): super(HeaderMatchChain, self).__init__( 'header-match', _('The built-in header matching chain')) - # The header match rules are not global, so don't register them. - # These are the only rules that the header match chain can execute. - self._links = [] - # Initialize header check rules with those from the global - # HEADER_MATCHES variable. - for entry in config.header_matches: - self._links.append(make_link(entry)) - # Keep track of how many global header matching rules we've seen. - # This is so the flush() method will only delete those that were added - # via extend() or append_link(). - self._permanent_link_count = len(self._links) + # This chain will dynamically calculate the links from the + # configuration file, the database, and any explicitly added header + # checks (via the .extend() method). + self._extended_links = [] - def extend(self, header, pattern, chain_name='hold'): + def extend(self, header, pattern): """Extend the existing header matches. :param header: The case-insensitive header field name. :param pattern: The pattern to match the header's value again. The match is not anchored and is done case-insensitively. - :param chain: Option chain to jump to if the pattern matches any of - the named header values. If not given, the 'hold' chain is used. """ - self._links.append(make_link((header, pattern, chain_name))) + self._extended_links.append(make_link(header, pattern)) def flush(self): """See `IMutableChain`.""" - del self._links[self._permanent_link_count:] + # Remove all dynamically created rules. Use the keys so we can mutate + # the dictionary inside the loop. + for rule_name in config.rules.keys(): + if rule_name.startswith('header-match-'): + del config.rules[rule_name] + self._extended_links = [] def get_links(self, mlist, msg, msgdata): """See `IChain`.""" - list_iterator = HeaderMatchIterator(mlist) - return itertools.chain(iter(self._links), iter(list_iterator)) - - def __iter__(self): - for link in self._links: + # First return all the configuration file links. + for line in config.antispam.header_checks.splitlines(): + if len(line.strip()) == 0: + continue + parts = line.split(':', 1) + if len(parts) != 2: + log.error('Configuration error: [antispam]header_checks ' + 'contains bogus line: {0}'.format(line)) + continue + yield make_link(parts[0], parts[1].lstrip()) + # Then return all the list-specific header matches. + # Python 3.3: Use 'yield from' + for entry in mlist.header_matches: + yield make_link(*entry) + # Then return all the explicitly added links. + for link in self._extended_links: yield link - - - -class HeaderMatchIterator: - """An iterator of both the global and list-specific chain links.""" - - implements(IChainIterator) - - def __init__(self, mlist): - self._mlist = mlist - - def __iter__(self): - """See `IChainIterator`.""" - for entry in self._mlist.header_matches: - yield make_link(entry) + # Finally, if any of the above rules matched, jump to the chain + # defined in the configuration file. + yield Link(config.rules['any'], LinkAction.jump, + config.chains[config.antispam.jump_chain]) diff --git a/src/mailman/chains/tests/test_headers.py b/src/mailman/chains/tests/test_headers.py new file mode 100644 index 000000000..65a23d891 --- /dev/null +++ b/src/mailman/chains/tests/test_headers.py @@ -0,0 +1,126 @@ +# Copyright (C) 2012 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Test the header chain.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + 'TestHeaderChain', + ] + + +import unittest + +from mailman.app.lifecycle import create_list +from mailman.chains.headers import HeaderMatchRule +from mailman.config import config +from mailman.email.message import Message +from mailman.interfaces.chain import LinkAction +from mailman.testing.layers import ConfigLayer +from mailman.testing.helpers import LogFileMark, configuration + + + +class TestHeaderChain(unittest.TestCase): + """Test the header chain code.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + # Python 2.6 does not have assertListEqual(). + self._leq = getattr(self, 'assertListEqual', self.assertEqual) + + @configuration('antispam', header_checks=""" + Foo: a+ + Bar: bb? + """) + def test_config_checks(self): + # Test that the header-match chain has the header checks from the + # configuration file. + chain = config.chains['header-match'] + # The links are created dynamically; the rule names will all start + # with the same prefix, but have a variable suffix. The actions will + # all be to jump to the named chain. Do these checks now, while we + # collect other useful information. + post_checks = [] + saw_any_rule = False + for link in chain.get_links(self._mlist, Message(), {}): + if link.rule.name == 'any': + saw_any_rule = True + self.assertEqual(link.action, LinkAction.jump) + elif saw_any_rule: + raise AssertionError("'any' rule was not last") + else: + self.assertEqual(link.rule.name[:13], 'header-match-') + self.assertEqual(link.action, LinkAction.defer) + post_checks.append((link.rule.header, link.rule.pattern)) + self._leq(post_checks, [ + ('Foo', 'a+'), + ('Bar', 'bb?'), + ]) + + @configuration('antispam', header_checks=""" + Foo: foo + A-bad-line + Bar: bar + """) + def test_bad_configuration_line(self): + # Take a mark on the error log file. + mark = LogFileMark('mailman.error') + # A bad value in [antispam]header_checks should just get ignored, but + # with an error message logged. + chain = config.chains['header-match'] + # The links are created dynamically; the rule names will all start + # with the same prefix, but have a variable suffix. The actions will + # all be to jump to the named chain. Do these checks now, while we + # collect other useful information. + post_checks = [] + saw_any_rule = False + for link in chain.get_links(self._mlist, Message(), {}): + if link.rule.name == 'any': + saw_any_rule = True + self.assertEqual(link.action, LinkAction.jump) + elif saw_any_rule: + raise AssertionError("'any' rule was not last") + else: + self.assertEqual(link.rule.name[:13], 'header-match-') + self.assertEqual(link.action, LinkAction.defer) + post_checks.append((link.rule.header, link.rule.pattern)) + self._leq(post_checks, [ + ('Foo', 'foo'), + ('Bar', 'bar'), + ]) + # Check the error log. + self.assertEqual(mark.readline()[-77:-1], + 'Configuration error: [antispam]header_checks ' + 'contains bogus line: A-bad-line') + + def test_duplicate_header_match_rule(self): + # 100% coverage: test an assertion in a corner case. + # + # Save the existing rules so they can be restored later. + saved_rules = config.rules.copy() + next_rule_name = 'header-match-{0:02}'.format(HeaderMatchRule._count) + config.rules[next_rule_name] = object() + try: + self.assertRaises(AssertionError, + HeaderMatchRule, 'x-spam-score', '.*') + finally: + config.rules = saved_rules diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index 47ef021b5..48c849148 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -250,13 +250,3 @@ class Configuration: """Iterate over all the style configuration sections.""" for section in self._config.getByCategory('style', []): yield section - - @property - def header_matches(self): - """Iterate over all spam matching headers. - - Values are 3-tuples of (header, pattern, chain) - """ - matches = self._config.getByCategory('spam.headers', []) - for match in matches: - yield (matches.header, matches.pattern, matches.chain) diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 44cbf6f4e..00b8d9325 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -339,19 +339,24 @@ charset: us-ascii enabled: yes -[spam.headers.template] -# This section defines basic header matching actions. Each spam.header -# section names a header to match (case-insensitively), a pattern to match -# against the header's value, and the chain to jump to when the match -# succeeds. +[antispam] +# This section defines basic antispam detection settings. + +# This value contains lines which specify RFC 822 headers in the email to +# check for spamminess. Each line contains a `key: value` pair, where the key +# is the header to check and the value is a Python regular expression to match +# against the header's value. E.g.: +# +# X-Spam: (yes|maybe) # -# The header value should not include the trailing colon. -header: X-Spam -# The pattern is always matched with re.IGNORECASE. -pattern: xyz -# The chain to jump to if the pattern matches. Maybe be any existing chain -# such as 'discard', 'reject', 'hold', or 'accept'. -chain: hold +# The header value and regular expression are always matched +# case-insensitively. +header_checks: + +# The chain to jump to if any of the header patterns matches. This must be +# the name of an existing chain such as 'discard', 'reject', 'hold', or +# 'accept', otherwise 'hold' will be used. +jump_chain: hold [mta] diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 54f6ec83f..3fe67bb36 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -37,6 +37,13 @@ Configuration * Configuration schema variable changes: [nntp]username -> [nntp]user [nntp]port (added) + * Header check specifications in the `mailman.cfg` file have changed quite + bit. The previous `[spam.header.foo]` sections have been removed. + Instead, there's a new `[antispam]` section that contains a `header_checks` + variable. This variable takes multiple lines of `Header: regexp` values, + one per line. There is also a new `jump_chain` variable which names the + chain to jump to should any of the header checks (including the + list-specific, and programmatically added ones) match. Documentation ------------- @@ -47,6 +54,8 @@ Bug fixes --------- * Fixed a UnicodeError with non-ascii message bodies in the `approved` rule, given by Mark Sapiro. (LP: #949924) + * Fixed a typo when returning the configuration file's header match checks. + (LP: #953497) 3.0 beta 1 -- "The Twilight Zone" diff --git a/src/mailman/interfaces/chain.py b/src/mailman/interfaces/chain.py index 2dd3a8d7d..8858f874c 100644 --- a/src/mailman/interfaces/chain.py +++ b/src/mailman/interfaces/chain.py @@ -78,10 +78,10 @@ class IChain(Interface): def get_links(mlist, msg, msgdata): """Get an `IChainIterator` for processing. - :param mlist: the IMailingList object - :param msg: the message being processed - :param msgdata: the message metadata dictionary - :return: An `IChainIterator`. + :param mlist: The mailing list. + :param msg: The message being processed. + :param msgdata: The message metadata dictionary. + :return: `IChainIterator`. """ diff --git a/src/mailman/rules/any.py b/src/mailman/rules/any.py index fa42b64cc..b0d147bec 100644 --- a/src/mailman/rules/any.py +++ b/src/mailman/rules/any.py @@ -42,4 +42,4 @@ class Any: def check(self, mlist, msg, msgdata): """See `IRule`.""" - return len(msgdata.get('rules', [])) > 0 + return len(msgdata.get('rule_hits', [])) > 0 diff --git a/src/mailman/rules/docs/header-matching.rst b/src/mailman/rules/docs/header-matching.rst index b07118e11..021974e69 100644 --- a/src/mailman/rules/docs/header-matching.rst +++ b/src/mailman/rules/docs/header-matching.rst @@ -4,95 +4,113 @@ Header matching Mailman can do pattern based header matching during its normal rule processing. There is a set of site-wide default header matches specified in -the configuration file under the ``[spam.headers]`` section. +the configuration file under the `[antispam]` section. >>> mlist = create_list('test@example.com') -Because the default ``[spam.headers]`` section is empty, we'll just extend the -current header matching chain with a pattern that matches 4 or more stars, -discarding the message if it hits. +In this section, the variable `header_checks` contains a list of the headers +to check, and the patterns to check them against. By default, this list is +empty. + +It is also possible to programmatically extend these header checks. Here, +we'll extend the checks with a pattern that matches 4 or more stars. >>> chain = config.chains['header-match'] - >>> chain.extend('x-spam-score', '[*]{4,}', 'discard') + >>> chain.extend('x-spam-score', '[*]{4,}') First, if the message has no ``X-Spam-Score:`` header, the message passes -through the chain untouched (i.e. no disposition). -:: +through the chain with no matches. >>> msg = message_from_string("""\ ... From: aperson@example.com ... To: test@example.com ... Subject: Not spam - ... Message-ID: <one> + ... Message-ID: <ant> ... ... This is a message. ... """) - >>> from mailman.core.chains import process +.. Function to help with printing rule hits and misses. + >>> def hits_and_misses(msgdata): + ... hits = msgdata.get('rule_hits', []) + ... if len(hits) == 0: + ... print 'No rules hit' + ... else: + ... print 'Rule hits:' + ... for rule_name in hits: + ... rule = config.rules[rule_name] + ... print ' {0}: {1}'.format(rule.header, rule.pattern) + ... misses = msgdata.get('rule_misses', []) + ... if len(misses) == 0: + ... print 'No rules missed' + ... else: + ... print 'Rule misses:' + ... for rule_name in misses: + ... rule = config.rules[rule_name] + ... print ' {0}: {1}'.format(rule.header, rule.pattern) -Pass through is seen as nothing being in the log file after processing. -:: +By looking at the message metadata after chain processing, we can see that +none of the rules matched. - # XXX This checks the vette log file because there is no other evidence - # that this chain has done anything. - >>> import os - >>> fp = open(os.path.join(config.LOG_DIR, 'vette')) - >>> fp.seek(0, 2) - >>> file_pos = fp.tell() - >>> process(mlist, msg, {}, 'header-match') - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: - <BLANKLINE> + >>> from mailman.core.chains import process + >>> msgdata = {} + >>> process(mlist, msg, msgdata, 'header-match') + >>> hits_and_misses(msgdata) + No rules hit + Rule misses: + x-spam-score: [*]{4,} -Now, if the header exists but does not match, then it also passes through -untouched. +The header may exist but does not match the pattern. >>> msg['X-Spam-Score'] = '***' - >>> del msg['subject'] - >>> msg['Subject'] = 'This is almost spam' - >>> del msg['message-id'] - >>> msg['Message-ID'] = '<two>' - >>> file_pos = fp.tell() - >>> process(mlist, msg, {}, 'header-match') - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: - <BLANKLINE> + >>> msgdata = {} + >>> process(mlist, msg, msgdata, 'header-match') + >>> hits_and_misses(msgdata) + No rules hit + Rule misses: + x-spam-score: [*]{4,} + +The header may exist and match the pattern. By default, when the header +matches, it gets held for moderator approval. +:: -But now if the header matches, then the message gets discarded. + >>> from mailman.testing.helpers import event_subscribers + >>> def handler(event): + ... print event.__class__.__name__, \ + ... event.chain.name, event.msg['message-id'] >>> del msg['x-spam-score'] - >>> msg['X-Spam-Score'] = '****' - >>> del msg['subject'] - >>> msg['Subject'] = 'This is spam, but barely' - >>> del msg['message-id'] - >>> msg['Message-ID'] = '<three>' - >>> file_pos = fp.tell() - >>> process(mlist, msg, {}, 'header-match') - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: ... DISCARD: <three> - <BLANKLINE> + >>> msg['X-Spam-Score'] = '*****' + >>> msgdata = {} + >>> with event_subscribers(handler): + ... process(mlist, msg, msgdata, 'header-match') + HoldNotification hold <ant> -For kicks, let's show a message that's really spammy. + >>> hits_and_misses(msgdata) + Rule hits: + x-spam-score: [*]{4,} + No rules missed - >>> del msg['x-spam-score'] - >>> msg['X-Spam-Score'] = '**********' - >>> del msg['subject'] - >>> msg['Subject'] = 'This is really spammy' - >>> del msg['message-id'] - >>> msg['Message-ID'] = '<four>' - >>> file_pos = fp.tell() - >>> process(mlist, msg, {}, 'header-match') - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: ... DISCARD: <four> - <BLANKLINE> +The configuration file can also specify a different final disposition for +messages that match their header checks. For example, we may just want to +discard such messages. -Flush out the extended header matching rules. + >>> from mailman.testing.helpers import configuration + >>> msgdata = {} + >>> with event_subscribers(handler): + ... with configuration('antispam', jump_chain='discard'): + ... process(mlist, msg, msgdata, 'header-match') + DiscardNotification discard <ant> + +These programmatically added headers can be removed by flushing the chain. +Now, nothing with match this message. >>> chain.flush() + >>> msgdata = {} + >>> process(mlist, msg, msgdata, 'header-match') + >>> hits_and_misses(msgdata) + No rules hit + No rules missed List-specific header matching @@ -100,47 +118,48 @@ List-specific header matching Each mailing list can also be configured with a set of header matching regular expression rules. These are used to impose list-specific header filtering -with the same semantics as the global ``[spam.headers]`` section. +with the same semantics as the global `[antispam]` section. The list administrator wants to match not on four stars, but on three plus signs, but only for the current mailing list. - >>> mlist.header_matches = [('x-spam-score', '[+]{3,}', 'discard')] + >>> mlist.header_matches = [('x-spam-score', '[+]{3,}')] A message with a spam score of two pluses does not match. + >>> msgdata = {} >>> del msg['x-spam-score'] >>> msg['X-Spam-Score'] = '++' - >>> del msg['message-id'] - >>> msg['Message-ID'] = '<five>' - >>> file_pos = fp.tell() - >>> process(mlist, msg, {}, 'header-match') - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: + >>> process(mlist, msg, msgdata, 'header-match') + >>> hits_and_misses(msgdata) + No rules hit + Rule misses: + x-spam-score: [+]{3,} -A message with a spam score of three pluses does match. +But a message with a spam score of three pluses does match. Because a message +with the previous Message-Id is already in the moderation queue, we need to +give this message a new Message-Id. + >>> msgdata = {} >>> del msg['x-spam-score'] >>> msg['X-Spam-Score'] = '+++' >>> del msg['message-id'] - >>> msg['Message-ID'] = '<six>' - >>> file_pos = fp.tell() - >>> process(mlist, msg, {}, 'header-match') - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: ... DISCARD: <six> - <BLANKLINE> + >>> msg['Message-Id'] = '<bee>' + >>> process(mlist, msg, msgdata, 'header-match') + >>> hits_and_misses(msgdata) + Rule hits: + x-spam-score: [+]{3,} + No rules missed As does a message with a spam score of four pluses. + >>> msgdata = {} >>> del msg['x-spam-score'] - >>> msg['X-Spam-Score'] = '+++' + >>> msg['X-Spam-Score'] = '++++' >>> del msg['message-id'] - >>> msg['Message-ID'] = '<seven>' - >>> file_pos = fp.tell() - >>> process(mlist, msg, {}, 'header-match') - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: ... DISCARD: <seven> - <BLANKLINE> + >>> msg['Message-Id'] = '<cat>' + >>> process(mlist, msg, msgdata, 'header-match') + >>> hits_and_misses(msgdata) + Rule hits: + x-spam-score: [+]{3,} + No rules missed diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py index 8295ef3a8..3648a6710 100644 --- a/src/mailman/testing/helpers.py +++ b/src/mailman/testing/helpers.py @@ -442,6 +442,8 @@ def reset_the_world(): config.db.commit() # Reset the global style manager. getUtility(IStyleManager).populate() + # Remove all dynamic header-match rules. + config.chains['header-match'].flush() |
