summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mailman/chains/tests/test_headers.py27
-rw-r--r--src/mailman/config/configure.zcml6
-rw-r--r--src/mailman/interfaces/mailinglist.py31
-rw-r--r--src/mailman/model/mailinglist.py50
-rw-r--r--src/mailman/model/tests/test_mailinglist.py46
-rw-r--r--src/mailman/rules/docs/header-matching.rst12
-rw-r--r--src/mailman/utilities/importer.py12
-rw-r--r--src/mailman/utilities/tests/test_import.py25
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):