Commit 87f2f50b authored by Aurélien Bompard's avatar Aurélien Bompard Committed by Barry Warsaw

Use and interface for the set of header_matches

parent e2f6b111
......@@ -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 = []
......
......@@ -34,6 +34,12 @@
factory="mailman.model.mailinglist.ListArchiverSet"
/>
<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"
......
......@@ -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
"""
......@@ -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)
......@@ -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(['[email protected]'], 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('[email protected]')
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)
......@@ -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.
......
......@@ -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
......
......@@ -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):
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment