summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAurélien Bompard2016-02-02 18:37:35 +0100
committerBarry Warsaw2016-02-29 21:52:13 -0500
commit7604ceaae42beb8d001e0ec6850bcd65164d56d7 (patch)
tree51e1b68bdc6ce408fce8cba98c7c55fd21965528
parent994660913bbd7dc08b8cef909b6715f43d37f0d5 (diff)
downloadmailman-7604ceaae42beb8d001e0ec6850bcd65164d56d7.tar.gz
mailman-7604ceaae42beb8d001e0ec6850bcd65164d56d7.tar.zst
mailman-7604ceaae42beb8d001e0ec6850bcd65164d56d7.zip
-rw-r--r--src/mailman/rest/docs/listconf.rst174
-rw-r--r--src/mailman/rest/header_matches.py165
-rw-r--r--src/mailman/rest/lists.py10
-rw-r--r--src/mailman/rest/tests/test_header_matches.py71
4 files changed, 419 insertions, 1 deletions
diff --git a/src/mailman/rest/docs/listconf.rst b/src/mailman/rest/docs/listconf.rst
index 8a7e2c526..4ad19bb13 100644
--- a/src/mailman/rest/docs/listconf.rst
+++ b/src/mailman/rest/docs/listconf.rst
@@ -293,3 +293,177 @@ The mailing list has its aliases set.
... print(alias)
bar@example.net
foo@example.com
+
+
+Header matches
+--------------
+
+Mailman can do pattern based header matching during its normal rule
+processing. Each mailing list can also be configured with a set of header
+matching regular expression rules. These can be used to impose list-specific
+header filtering with the same semantics as the global ``[antispam]`` section,
+or to have a different action.
+
+The list of header matches for a mailing list are returned on the
+``header-matches`` child of this list.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches')
+ http_etag: "..."
+ start: 0
+ total_size: 0
+
+New header matches can be created by POSTing to the resource.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches', {
+ ... 'header': 'X-Spam-Flag',
+ ... 'pattern': '^Yes',
+ ... })
+ content-length: 0
+ ...
+ location: .../3.0/lists/ant.example.com/header-matches/0
+ ...
+ status: 201
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches/0')
+ header: x-spam-flag
+ http_etag: "..."
+ index: 0
+ pattern: ^Yes
+ self_link: http://localhost:9001/3.0/lists/ant.example.com/header-matches/0
+
+
+To follow the global antispam action, the header match rule must not specify
+an ``action`` key. If the default antispam action is changed in the
+configuration file and Mailman is restarted, those rules will get the new
+jump action. If a specific action is desired, the ``action`` key must point
+to a valid action.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches', {
+ ... 'header': 'X-Spam-Status',
+ ... 'pattern': '^Yes',
+ ... 'action': 'discard',
+ ... })
+ content-length: 0
+ ...
+ location: .../3.0/lists/ant.example.com/header-matches/1
+ ...
+ status: 201
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches/1')
+ action: discard
+ header: x-spam-status
+ http_etag: "..."
+ index: 1
+ pattern: ^Yes
+ self_link: http://localhost:9001/3.0/lists/ant.example.com/header-matches/1
+
+The resource can be changed by PATCHing it. The ``index`` key can be used to
+change the priority of the header match in the list. If it is not supplied, the
+priority is not changed.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches/1',
+ ... dict(pattern='^No', action='accept'),
+ ... 'PATCH')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches/1')
+ action: accept
+ header: x-spam-status
+ http_etag: "..."
+ index: 1
+ pattern: ^No
+ self_link: http://localhost:9001/3.0/lists/ant.example.com/header-matches/1
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches/1',
+ ... dict(index=0),
+ ... 'PATCH')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches')
+ entry 0:
+ action: accept
+ header: x-spam-status
+ http_etag: "..."
+ index: 0
+ pattern: ^No
+ self_link: http://localhost:9001/3.0/lists/ant.example.com/header-matches/0
+ entry 1:
+ header: x-spam-flag
+ http_etag: "..."
+ index: 1
+ pattern: ^Yes
+ self_link: http://localhost:9001/3.0/lists/ant.example.com/header-matches/1
+ http_etag: "..."
+ start: 0
+ total_size: 2
+
+The PUT method can replace an entire header match. The ``index`` key is
+optional: if it is omitted, the order will not be changed.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches/1',
+ ... dict(header='X-Spam-Status',
+ ... pattern='^Yes',
+ ... action='hold',
+ ... ), 'PUT')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches/1')
+ action: hold
+ header: x-spam-status
+ http_etag: "..."
+ index: 1
+ pattern: ^Yes
+ self_link: http://localhost:9001/3.0/lists/ant.example.com/header-matches/1
+
+A header match can be removed using the DELETE method.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches/1',
+ ... method='DELETE')
+ content-length: 0
+ ...
+ status: 204
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches')
+ entry 0:
+ action: accept
+ header: x-spam-status
+ http_etag: "..."
+ index: 0
+ pattern: ^No
+ self_link: http://localhost:9001/3.0/lists/ant.example.com/header-matches/0
+ http_etag: "..."
+ start: 0
+ total_size: 1
+
+The mailing list's header matches can be cleared by issuing a DELETE request on the top resource.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches',
+ ... method='DELETE')
+ content-length: 0
+ ...
+ status: 204
+ >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com'
+ ... '/header-matches')
+ http_etag: "..."
+ start: 0
+ total_size: 0
diff --git a/src/mailman/rest/header_matches.py b/src/mailman/rest/header_matches.py
new file mode 100644
index 000000000..d93a90fd6
--- /dev/null
+++ b/src/mailman/rest/header_matches.py
@@ -0,0 +1,165 @@
+# Copyright (C) 2016 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/>.
+
+"""REST API for a mailing list's header matches."""
+
+__all__ = [
+ 'HeaderMatch',
+ 'HeaderMatches',
+ ]
+
+
+from mailman.interfaces.action import Action
+from mailman.interfaces.mailinglist import IHeaderMatchList
+from mailman.rest.helpers import (
+ CollectionMixin, GetterSetter, bad_request, child, created, etag,
+ no_content, not_found, okay)
+from mailman.rest.validator import Validator, enum_validator
+
+
+def lowercase(value):
+ return str(value).lower()
+
+
+class _HeaderMatchBase:
+ """Common base class."""
+
+ def __init__(self, mlist):
+ self._mlist = mlist
+ self.header_matches = IHeaderMatchList(self._mlist)
+
+ def _location(self, index):
+ return self.api.path_to('lists/{}/header-matches/{}'.format(self._mlist.list_id, index))
+
+ def _resource_as_dict(self, header_match):
+ """See `CollectionMixin`."""
+ resource = dict(
+ index=header_match.index,
+ header=header_match.header,
+ pattern=header_match.pattern,
+ self_link=self._location(header_match.index),
+ )
+ if header_match.action is not None:
+ resource['action'] = header_match.action
+ return resource
+
+
+class HeaderMatch(_HeaderMatchBase):
+ """A header match."""
+
+ def __init__(self, mlist, index):
+ super().__init__(mlist)
+ self._index = index
+
+ def on_get(self, request, response):
+ """Get a header match."""
+ try:
+ header_match = self.header_matches[self._index]
+ except IndexError:
+ not_found(response, 'No header match at this index: {}'.format(
+ self._index))
+ else:
+ okay(response, etag(self._resource_as_dict(header_match)))
+
+ def on_delete(self, request, response):
+ """Remove a header match."""
+ try:
+ del self.header_matches[self._index]
+ except IndexError:
+ not_found(response, 'No header match at this index: {}'.format(
+ self._index))
+ else:
+ no_content(response)
+
+ def patch_put(self, request, response, is_optional):
+ """Update the header match."""
+ try:
+ header_match = self.header_matches[self._index]
+ except IndexError:
+ not_found(response, 'No header match at this index: {}'.format(
+ self._index))
+ return
+ kws = dict(
+ header=GetterSetter(lowercase),
+ pattern=GetterSetter(str),
+ index=GetterSetter(int),
+ action=GetterSetter(enum_validator(Action)),
+ )
+ if is_optional:
+ # For a PATCH, all attributes are optional.
+ kws['_optional'] = kws.keys()
+ else:
+ # For a PUT, index can remain unchanged and action can be None.
+ kws['_optional'] = ('action', 'index')
+ try:
+ Validator(**kws).update(header_match, request)
+ except ValueError as error:
+ bad_request(response, str(error))
+ return
+ else:
+ no_content(response)
+
+ def on_put(self, request, response):
+ """Full update of the header match."""
+ self.patch_put(request, response, is_optional=False)
+
+ def on_patch(self, request, response):
+ """Partial update of the header match."""
+ self.patch_put(request, response, is_optional=True)
+
+
+class HeaderMatches(_HeaderMatchBase, CollectionMixin):
+ """The list of all header matches."""
+
+ def _get_collection(self, request):
+ """See `CollectionMixin`."""
+ return list(self.header_matches)
+
+ def on_get(self, request, response):
+ """/header-matches"""
+ resource = self._make_collection(request)
+ okay(response, etag(resource))
+
+ def on_post(self, request, response):
+ """Add a header match."""
+ validator = Validator(
+ header=str,
+ pattern=str,
+ action=enum_validator(Action),
+ _optional=('action',)
+ )
+ try:
+ arguments = validator(request)
+ except ValueError as error:
+ bad_request(response, str(error))
+ return
+ try:
+ self.header_matches.append(**arguments)
+ except ValueError:
+ bad_request(response, b'This header match already exists')
+ else:
+ header_match = self.header_matches[-1]
+ created(response, self._location(header_match.index))
+
+ def on_delete(self, request, response):
+ """Delete all header matches for this mailing list."""
+ self.header_matches.clear()
+ no_content(response)
+
+ @child(r'^(?P<index>\d+)')
+ def header_match(self, request, segments, **kw):
+ return HeaderMatch(self._mlist, int(kw['index']))
diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py
index 42814857b..1ef077243 100644
--- a/src/mailman/rest/lists.py
+++ b/src/mailman/rest/lists.py
@@ -41,6 +41,7 @@ from mailman.interfaces.styles import IStyleManager
from mailman.interfaces.subscriptions import ISubscriptionService
from mailman.rest.bans import BannedEmails
from mailman.rest.listconf import ListConfiguration
+from mailman.rest.header_matches import HeaderMatches
from mailman.rest.helpers import (
CollectionMixin, GetterSetter, NotFound, accepted, bad_request, child,
created, etag, no_content, not_found, okay)
@@ -206,6 +207,13 @@ class AList(_ListBase):
return NotFound(), []
return BannedEmails(self._mlist)
+ @child(r'^header-matches')
+ def header_matches(self, request, segments):
+ """Return a collection of mailing list's header matches."""
+ if self._mlist is None:
+ return NotFound(), []
+ return HeaderMatches(self._mlist)
+
class AllLists(_ListBase):
@@ -302,7 +310,7 @@ class ListArchivers:
kws = {archiver.name: ArchiverGetterSetter(self._mlist)
for archiver in archiver_set.archivers}
if is_optional:
- # For a PUT, all attributes are optional.
+ # For a PATCH, all attributes are optional.
kws['_optional'] = kws.keys()
try:
Validator(**kws).update(self._mlist, request)
diff --git a/src/mailman/rest/tests/test_header_matches.py b/src/mailman/rest/tests/test_header_matches.py
new file mode 100644
index 000000000..4568a2ebf
--- /dev/null
+++ b/src/mailman/rest/tests/test_header_matches.py
@@ -0,0 +1,71 @@
+# Copyright (C) 2016 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 REST header matches."""
+
+__all__ = [
+ 'TestHeaderMatches',
+ ]
+
+
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.database.transaction import transaction
+from mailman.interfaces.mailinglist import IHeaderMatchList
+from mailman.testing.layers import RESTLayer
+from mailman.testing.helpers import call_api
+from urllib.error import HTTPError
+
+
+class TestHeaderMatches(unittest.TestCase):
+ layer = RESTLayer
+
+ def setUp(self):
+ with transaction():
+ self._mlist = create_list('ant@example.com')
+
+ def test_get_missing_hm(self):
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/ant.example.com'
+ '/header-matches/0')
+ self.assertEqual(cm.exception.code, 404)
+ self.assertEqual(cm.exception.reason,
+ b'No header match at this index: 0')
+
+ def test_delete_missing_hm(self):
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/ant.example.com'
+ '/header-matches/0',
+ method='DELETE')
+ self.assertEqual(cm.exception.code, 404)
+ self.assertEqual(cm.exception.reason,
+ b'No header match at this index: 0')
+
+ def test_add_duplicate(self):
+ header_matches = IHeaderMatchList(self._mlist)
+ with transaction():
+ header_matches.append('header', 'pattern')
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/ant.example.com'
+ '/header-matches', {
+ 'header': 'header',
+ 'pattern': 'pattern',
+ })
+ self.assertEqual(cm.exception.code, 400)
+ self.assertEqual(cm.exception.reason,
+ b'This header match already exists')