diff options
| -rw-r--r-- | src/mailman/rest/docs/listconf.rst | 174 | ||||
| -rw-r--r-- | src/mailman/rest/header_matches.py | 165 | ||||
| -rw-r--r-- | src/mailman/rest/lists.py | 10 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_header_matches.py | 71 |
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') |
