summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/app/bounces.py3
-rw-r--r--src/mailman/app/tests/test_bounces.py65
-rw-r--r--src/mailman/chains/headers.py114
-rw-r--r--src/mailman/chains/tests/test_headers.py126
-rw-r--r--src/mailman/config/config.py10
-rw-r--r--src/mailman/config/schema.cfg29
-rw-r--r--src/mailman/docs/NEWS.rst9
-rw-r--r--src/mailman/interfaces/chain.py8
-rw-r--r--src/mailman/rules/any.py2
-rw-r--r--src/mailman/rules/docs/header-matching.rst189
-rw-r--r--src/mailman/testing/helpers.py2
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()