diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/app/bounces.py | 2 | ||||
| -rw-r--r-- | src/mailman/app/moderator.py | 6 | ||||
| -rw-r--r-- | src/mailman/archiving/mailarchive.py | 2 | ||||
| -rw-r--r-- | src/mailman/archiving/mhonarc.py | 2 | ||||
| -rw-r--r-- | src/mailman/archiving/prototype.py | 2 | ||||
| -rw-r--r-- | src/mailman/commands/eml_membership.py | 2 | ||||
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 12 | ||||
| -rw-r--r-- | src/mailman/email/message.py | 2 | ||||
| -rw-r--r-- | src/mailman/email/tests/test_message.py | 15 | ||||
| -rw-r--r-- | src/mailman/model/bounce.py | 2 | ||||
| -rw-r--r-- | src/mailman/model/messagestore.py | 3 | ||||
| -rw-r--r-- | src/mailman/rest/addresses.py | 11 | ||||
| -rw-r--r-- | src/mailman/rest/docs/addresses.rst | 5 | ||||
| -rw-r--r-- | src/mailman/rest/docs/users.rst | 4 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_addresses.py | 174 | ||||
| -rw-r--r-- | src/mailman/rest/users.py | 151 | ||||
| -rw-r--r-- | src/mailman/rules/approved.py | 3 | ||||
| -rw-r--r-- | src/mailman/rules/implicit_dest.py | 2 | ||||
| -rw-r--r-- | src/mailman/rules/tests/test_approved.py | 32 | ||||
| -rw-r--r-- | src/mailman/utilities/email.py | 2 |
20 files changed, 390 insertions, 44 deletions
diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py index 34b3784c5..b0a316ad6 100644 --- a/src/mailman/app/bounces.py +++ b/src/mailman/app/bounces.py @@ -202,7 +202,7 @@ def send_probe(member, msg): ) message_id = msg['message-id'] if isinstance(message_id, bytes): - message_id = message_id.decode("ascii") + message_id = message_id.decode('ascii') pendable = _ProbePendable( # We can only pend unicodes. member_id=member.member_id.hex, diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py index 6ada9249f..105e53617 100644 --- a/src/mailman/app/moderator.py +++ b/src/mailman/app/moderator.py @@ -86,9 +86,9 @@ def hold_message(mlist, msg, msgdata=None, reason=None): # Message-ID header. message_id = msg.get('message-id') if message_id is None: - msg['Message-ID'] = message_id = make_msgid().decode("ascii") - if isinstance(message_id, bytes): - message_id = message_id.decode("ascii") + msg['Message-ID'] = message_id = make_msgid().decode('ascii') + elif isinstance(message_id, bytes): + message_id = message_id.decode('ascii') getUtility(IMessageStore).add(msg) # Prepare the message metadata with some extra information needed only by # the moderation interface. diff --git a/src/mailman/archiving/mailarchive.py b/src/mailman/archiving/mailarchive.py index f22eb7fb2..c5fe5d0cb 100644 --- a/src/mailman/archiving/mailarchive.py +++ b/src/mailman/archiving/mailarchive.py @@ -69,7 +69,7 @@ class MailArchive: if message_id_hash is None: return None if isinstance(message_id_hash, bytes): - message_id_hash = message_id_hash.decode("ascii") + message_id_hash = message_id_hash.decode('ascii') return urljoin(self.base_url, message_id_hash) def archive_message(self, mlist, msg): diff --git a/src/mailman/archiving/mhonarc.py b/src/mailman/archiving/mhonarc.py index e773860e3..f2d1f77fe 100644 --- a/src/mailman/archiving/mhonarc.py +++ b/src/mailman/archiving/mhonarc.py @@ -74,7 +74,7 @@ class MHonArc: if message_id_hash is None: return None if isinstance(message_id_hash, bytes): - message_id_hash = message_id_hash.decode("ascii") + message_id_hash = message_id_hash.decode('ascii') return urljoin(self.list_url(mlist), message_id_hash) def archive_message(self, mlist, msg): diff --git a/src/mailman/archiving/prototype.py b/src/mailman/archiving/prototype.py index 6ef781c0c..77b2294ed 100644 --- a/src/mailman/archiving/prototype.py +++ b/src/mailman/archiving/prototype.py @@ -69,7 +69,7 @@ class Prototype: if message_id_hash is None: return None if isinstance(message_id_hash, bytes): - message_id_hash = message_id_hash.decode("ascii") + message_id_hash = message_id_hash.decode('ascii') return urljoin(Prototype.list_url(mlist), message_id_hash) @staticmethod diff --git a/src/mailman/commands/eml_membership.py b/src/mailman/commands/eml_membership.py index d88406ae8..c56b14041 100644 --- a/src/mailman/commands/eml_membership.py +++ b/src/mailman/commands/eml_membership.py @@ -73,7 +73,7 @@ used. file=results) return ContinueProcessing.no if isinstance(address, bytes): - address = address.decode("ascii") + address = address.decode('ascii') # Have we already seen one join request from this user during the # processing of this email? joins = getattr(results, 'joins', set()) diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index ac81cd386..9b37ca7e8 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -16,9 +16,13 @@ Bugs ---- * Fixed Unicode errors in the digest runner and when sending messages to the site owner as a fallback. Given by Aurélien Bompard. (LP: #1130957). - * Fix Unicode errors when a message being added to the digest has non-ascii + * Fixed Unicode errors when a message being added to the digest has non-ascii characters in its payload, but no Content-Type header defining a charset. Given by Aurélien Bompard. (LP: #1170347) + * Fixed messages without a `text/plain` part crashing the `Approved` rule. + Given by Aurélien Bompard. (LP: #1158721) + * Fixed getting non-ASCII filenames from RFC 2231 i18n'd messages. Given by + Aurélien Bompard. (LP: #1060951) Commands -------- @@ -61,6 +65,12 @@ REST internal change only. * The JSON representation `http_etag` key uses an algorithm that is insensitive to Python's dictionary sort order. + * The address resource now has an additional '/user' sub-resource which can + be used to GET the address's linked user if there is one. This + sub-resource also supports POST to link an unlinked address (with an + optional 'auto_create' flag), and PUT to link the address to a different + user. It also supports DELETE to unlink the address. (LP: #1312884) + Given by Aurélien Bompard based on work by Nicolas Karageuzian. 3.0 beta 4 -- "Time and Motion" diff --git a/src/mailman/email/message.py b/src/mailman/email/message.py index 6ddedc48e..24c6ead9e 100644 --- a/src/mailman/email/message.py +++ b/src/mailman/email/message.py @@ -127,7 +127,7 @@ class Message(email.message.Message): if not sender: continue if isinstance(sender, bytes): - sender = sender.decode("ascii") + sender = sender.decode('ascii') clean_senders.append(sender) return clean_senders diff --git a/src/mailman/email/tests/test_message.py b/src/mailman/email/tests/test_message.py index 122562d0a..1fdef5e86 100644 --- a/src/mailman/email/tests/test_message.py +++ b/src/mailman/email/tests/test_message.py @@ -22,6 +22,7 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ 'TestMessage', + 'TestMessageSubclass', ] @@ -29,7 +30,7 @@ import unittest from email.parser import FeedParser from mailman.app.lifecycle import create_list -from mailman.email.message import UserNotification, Message +from mailman.email.message import Message, UserNotification from mailman.testing.helpers import get_queue_messages from mailman.testing.layers import ConfigLayer @@ -57,16 +58,16 @@ class TestMessage(unittest.TestCase): self._msg.send(self._mlist) messages = get_queue_messages('virgin') self.assertEqual(len(messages), 1) - self.assertEqual(messages[0].msg.get_all('precedence'), + self.assertEqual(messages[0].msg.get_all('precedence'), ['omg wtf bbq']) class TestMessageSubclass(unittest.TestCase): - def test_i18n_filenames(self): parser = FeedParser(_factory=Message) - parser.feed(b"""Message-ID: <blah@example.com> + parser.feed(b"""\ +Message-ID: <blah@example.com> Content-Type: multipart/mixed; boundary="------------050607040206050605060208" This is a multi-part message in MIME format. @@ -78,15 +79,15 @@ Test message containing an attachment with an accented filename --------------050607040206050605060208 Content-Disposition: attachment; - filename*=UTF-8''d%C3%A9jeuner.txt + filename*=UTF-8''d%C3%A9jeuner.txt Test content --------------050607040206050605060208-- """) msg = parser.close() - attachment = msg.get_payload()[1] + attachment = msg.get_payload(1) try: filename = attachment.get_filename() except TypeError as e: self.fail(e) - self.assertEqual(filename, u"d\xe9jeuner.txt") + self.assertEqual(filename, u'd\xe9jeuner.txt') diff --git a/src/mailman/model/bounce.py b/src/mailman/model/bounce.py index 29a35266e..26ebbe0c6 100644 --- a/src/mailman/model/bounce.py +++ b/src/mailman/model/bounce.py @@ -59,7 +59,7 @@ class BounceEvent(Model): self.timestamp = now() msgid = msg['message-id'] if isinstance(msgid, bytes): - msgid = msgid.decode("ascii") + msgid = msgid.decode('ascii') self.message_id = msgid self.context = (BounceContext.normal if context is None else context) self.processed = False diff --git a/src/mailman/model/messagestore.py b/src/mailman/model/messagestore.py index 3b8aed9c5..12b2aef46 100644 --- a/src/mailman/model/messagestore.py +++ b/src/mailman/model/messagestore.py @@ -24,6 +24,7 @@ __all__ = [ 'MessageStore', ] + import os import errno import base64 @@ -59,7 +60,7 @@ class MessageStore: # Calculate and insert the X-Message-ID-Hash. message_id = message_ids[0] if isinstance(message_id, bytes): - message_id = message_id.decode("ascii") + message_id = message_id.decode('ascii') # Complain if the Message-ID already exists in the storage. existing = store.query(Message).filter( Message.message_id == message_id).first() diff --git a/src/mailman/rest/addresses.py b/src/mailman/rest/addresses.py index fa3d099b6..f8516bc37 100644 --- a/src/mailman/rest/addresses.py +++ b/src/mailman/rest/addresses.py @@ -62,6 +62,9 @@ class _AddressBase(CollectionMixin): representation['display_name'] = address.display_name if address.verified_on: representation['verified_on'] = address.verified_on + if address.user: + representation['user'] = path_to( + 'users/{0}'.format(address.user.user_id.int)) return representation def _get_collection(self, request): @@ -156,6 +159,14 @@ class AnAddress(_AddressBase): child = _VerifyResource(self._address, 'unverify') return child, [] + @child() + def user(self, request, segments): + """/addresses/<email>/user""" + if self._address is None: + return NotFound(), [] + # Avoid circular imports. + from mailman.rest.users import AddressUser + return AddressUser(self._address) class UserAddresses(_AddressBase): diff --git a/src/mailman/rest/docs/addresses.rst b/src/mailman/rest/docs/addresses.rst index fec0c194b..8d7ca6835 100644 --- a/src/mailman/rest/docs/addresses.rst +++ b/src/mailman/rest/docs/addresses.rst @@ -161,6 +161,7 @@ addresses live in the /addresses namespace. original_email: dave@example.com registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/dave@example.com + user: http://localhost:9001/3.0/users/1 http_etag: "..." start: 0 total_size: 1 @@ -172,6 +173,7 @@ addresses live in the /addresses namespace. original_email: dave@example.com registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/dave@example.com + user: http://localhost:9001/3.0/users/1 A user can be associated with multiple email addresses. You can add new addresses to an existing user. @@ -208,6 +210,7 @@ The user controls these new addresses. original_email: dave.person@example.org registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/dave.person@example.org + user: http://localhost:9001/3.0/users/1 entry 1: display_name: Dave Person email: dave@example.com @@ -215,6 +218,7 @@ The user controls these new addresses. original_email: dave@example.com registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/dave@example.com + user: http://localhost:9001/3.0/users/1 entry 2: display_name: Davie P email: dp@example.org @@ -222,6 +226,7 @@ The user controls these new addresses. original_email: dp@example.org registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/dp@example.org + user: http://localhost:9001/3.0/users/1 http_etag: "..." start: 0 total_size: 3 diff --git a/src/mailman/rest/docs/users.rst b/src/mailman/rest/docs/users.rst index 04533f578..b2adcaccb 100644 --- a/src/mailman/rest/docs/users.rst +++ b/src/mailman/rest/docs/users.rst @@ -329,18 +329,21 @@ order by original (i.e. case-preserved) email address. registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/fred.q.person@example.com + user: http://localhost:9001/3.0/users/6 entry 1: email: fperson@example.com http_etag: "..." original_email: fperson@example.com registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/fperson@example.com + user: http://localhost:9001/3.0/users/6 entry 2: email: fred.person@example.com http_etag: "..." original_email: fred.person@example.com registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/fred.person@example.com + user: http://localhost:9001/3.0/users/6 entry 3: display_name: Fred Person email: fred@example.com @@ -348,6 +351,7 @@ order by original (i.e. case-preserved) email address. original_email: fred@example.com registered_on: 2005-08-01T07:49:23 self_link: http://localhost:9001/3.0/addresses/fred@example.com + user: http://localhost:9001/3.0/users/6 http_etag: "..." start: 0 total_size: 4 diff --git a/src/mailman/rest/tests/test_addresses.py b/src/mailman/rest/tests/test_addresses.py index f4aeb3013..bbdd7d763 100644 --- a/src/mailman/rest/tests/test_addresses.py +++ b/src/mailman/rest/tests/test_addresses.py @@ -206,3 +206,177 @@ class TestAddresses(unittest.TestCase): 'email': 'anne.person@example.org', }) self.assertEqual(cm.exception.code, 404) + + def test_address_with_user(self): + # An address which is already linked to a user has a 'user' key in the + # JSON representation. + with transaction(): + getUtility(IUserManager).create_user('anne@example.com') + json, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com') + self.assertEqual(headers['status'], '200') + self.assertEqual(json['user'], 'http://localhost:9001/3.0/users/1') + + def test_address_without_user(self): + # The 'user' key is missing from the JSON representation of an address + # with no linked user. + with transaction(): + getUtility(IUserManager).create_address('anne@example.com') + json, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com') + self.assertEqual(headers['status'], '200') + self.assertNotIn('user', json) + + def test_user_subresource_on_unlinked_address(self): + # Trying to access the 'user' subresource on an address that is not + # linked to a user will return a 404 error. + with transaction(): + getUtility(IUserManager).create_address('anne@example.com') + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user') + self.assertEqual(cm.exception.code, 404) + + def test_user_subresource(self): + # For an address which is linked to a user, accessing the user + # subresource of the address path returns the user JSON representation. + user_manager = getUtility(IUserManager) + with transaction(): + user_manager.create_user('anne@example.com', 'Anne') + json, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user') + self.assertEqual(headers['status'], '200') + self.assertEqual(json['user_id'], 1) + self.assertEqual(json['display_name'], 'Anne') + user_resource = json['self_link'] + self.assertEqual(user_resource, 'http://localhost:9001/3.0/users/1') + # The self_link points to the correct user. + json, headers = call_api(user_resource) + self.assertEqual(json['user_id'], 1) + self.assertEqual(json['display_name'], 'Anne') + self.assertEqual(json['self_link'], user_resource) + + def test_user_subresource_post(self): + # If the address is not yet linked to a user, POSTing a user id to the + # 'user' subresource links the address to the given user. + user_manager = getUtility(IUserManager) + with transaction(): + anne = user_manager.create_user('anne.person@example.org', 'Anne') + anne_addr = user_manager.create_address('anne@example.com') + response, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user', { + 'user_id': anne.user_id.int, + }) + self.assertEqual(headers['status'], '200') + self.assertEqual(anne_addr.user, anne) + self.assertEqual(sorted([a.email for a in anne.addresses]), + ['anne.person@example.org', 'anne@example.com']) + + def test_user_subresource_post_new_user(self): + # If the address is not yet linked to a user, POSTing to the 'user' + # subresources creates a new user object and links it to the address. + user_manager = getUtility(IUserManager) + with transaction(): + anne_addr = user_manager.create_address('anne@example.com') + response, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user', { + 'display_name': 'Anne', + }) + self.assertEqual(headers['status'], '201') + anne = user_manager.get_user('anne@example.com') + self.assertIsNotNone(anne) + self.assertEqual(anne.display_name, 'Anne') + self.assertEqual([a.email for a in anne.addresses], + ['anne@example.com']) + self.assertEqual(anne_addr.user, anne) + self.assertEqual(headers['location'], + 'http://localhost:9001/3.0/users/1') + + def test_user_subresource_post_conflict(self): + # If the address is already linked to a user, trying to link it to + # another user produces a 409 Conflict error. + with transaction(): + getUtility(IUserManager).create_user('anne@example.com') + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user', { + 'email': 'anne.person@example.org', + }) + self.assertEqual(cm.exception.code, 409) + + def test_user_subresource_post_new_user_no_auto_create(self): + # By default, POSTing to the 'user' resource of an unlinked address + # will automatically create the user. By setting a boolean + # 'auto_create' flag to false, you can prevent this. + with transaction(): + getUtility(IUserManager).create_address('anne@example.com') + with self.assertRaises(HTTPError) as cm: + json, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user', { + 'display_name': 'Anne', + 'auto_create': 0, + }) + self.assertEqual(cm.exception.code, 403) + + def test_user_subresource_unlink(self): + # By DELETEing the usr subresource, you can unlink a user from an + # address. + user_manager = getUtility(IUserManager) + with transaction(): + user_manager.create_user('anne@example.com') + response, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user', + method='DELETE') + self.assertEqual(headers['status'], '204') + anne_addr = user_manager.get_address('anne@example.com') + self.assertIsNone(anne_addr.user, 'The address is still linked') + self.assertIsNone(user_manager.get_user('anne@example.com')) + + def test_user_subresource_unlink_unlinked(self): + # If you try to unlink an unlinked address, you get a 404 error. + user_manager = getUtility(IUserManager) + with transaction(): + user_manager.create_address('anne@example.com') + with self.assertRaises(HTTPError) as cm: + response, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user', + method='DELETE') + self.assertEqual(cm.exception.code, 404) + + def test_user_subresource_put(self): + # By PUTing to the 'user' resource, you can change the user that an + # address is linked to. + user_manager = getUtility(IUserManager) + with transaction(): + anne = user_manager.create_user('anne@example.com', 'Anne') + bart = user_manager.create_user(display_name='Bart') + response, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user', { + 'user_id': bart.user_id.int, + }, method='PUT') + self.assertEqual(headers['status'], '200') + self.assertEqual(anne.addresses, []) + self.assertEqual([address.email for address in bart.addresses], + ['anne@example.com']) + self.assertEqual(bart, + user_manager.get_address('anne@example.com').user) + + def test_user_subresource_put_create(self): + # PUTing to the 'user' resource creates the user, just like with POST. + user_manager = getUtility(IUserManager) + with transaction(): + anne = user_manager.create_user('anne@example.com', 'Anne') + response, headers = call_api( + 'http://localhost:9001/3.0/addresses/anne@example.com/user', { + 'email': 'anne.person@example.org', + }, method='PUT') + self.assertEqual(headers['status'], '201') + self.assertEqual(anne.addresses, []) + anne_person = user_manager.get_user('anne.person@example.org') + self.assertIsNotNone(anne_person) + self.assertEqual( + sorted([address.email for address in anne_person.addresses]), + ['anne.person@example.org', 'anne@example.com']) + anne_addr = user_manager.get_address('anne@example.com') + self.assertIsNotNone(anne_addr) + self.assertEqual(anne_addr.user, anne_person) diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py index cfea36cfa..7ab1d6818 100644 --- a/src/mailman/rest/users.py +++ b/src/mailman/rest/users.py @@ -27,6 +27,7 @@ __all__ = [ ] +from lazr.config import as_boolean from passlib.utils import generate_password as generate from uuid import UUID from zope.component import getUtility @@ -39,7 +40,8 @@ from mailman.interfaces.usermanager import IUserManager from mailman.rest.addresses import UserAddresses from mailman.rest.helpers import ( BadRequest, CollectionMixin, GetterSetter, NotFound, bad_request, child, - created, etag, forbidden, no_content, not_found, okay, paginate, path_to) + conflict, created, etag, forbidden, no_content, not_found, okay, paginate, + path_to) from mailman.rest.preferences import Preferences from mailman.rest.validator import PatchValidator, Validator @@ -63,6 +65,35 @@ ATTRIBUTES = dict( ) +CREATION_FIELDS = dict( + email=unicode, + display_name=unicode, + password=unicode, + _optional=('display_name', 'password'), + ) + + +def create_user(arguments, response): + """Create a new user.""" + # We can't pass the 'password' argument to the user creation method, so + # strip that out (if it exists), then create the user, adding the password + # after the fact if successful. + password = arguments.pop('password', None) + try: + user = getUtility(IUserManager).create_user(**arguments) + except ExistingAddressError as error: + bad_request( + response, b'Address already exists: {}'.format(error.address)) + return None + if password is None: + # This will have to be reset since it cannot be retrieved. + password = generate(int(config.passwords.password_length)) + user.password = config.password_context.encrypt(password) + location = path_to('users/{}'.format(user.user_id.int)) + created(response, location) + return user + + class _UserBase(CollectionMixin): """Shared base class for user representations.""" @@ -77,7 +108,7 @@ class _UserBase(CollectionMixin): resource = dict( user_id=user_id, created_on=user.created_on, - self_link=path_to('users/{0}'.format(user_id)), + self_link=path_to('users/{}'.format(user_id)), ) # Add the password attribute, only if the user has a password. Same # with the real name. These could be None or the empty string. @@ -105,30 +136,12 @@ class AllUsers(_UserBase): def on_post(self, request, response): """Create a new user.""" try: - validator = Validator(email=unicode, - display_name=unicode, - password=unicode, - _optional=('display_name', 'password')) + validator = Validator(**CREATION_FIELDS) arguments = validator(request) except ValueError as error: bad_request(response, str(error)) return - # We can't pass the 'password' argument to the user creation method, - # so strip that out (if it exists), then create the user, adding the - # password after the fact if successful. - password = arguments.pop('password', None) - try: - user = getUtility(IUserManager).create_user(**arguments) - except ExistingAddressError as error: - bad_request( - response, b'Address already exists: {0}'.format(error.address)) - return - if password is None: - # This will have to be reset since it cannot be retrieved. - password = generate(int(config.passwords.password_length)) - user.password = config.password_context.encrypt(password) - location = path_to('users/{0}'.format(user.user_id.int)) - created(response, location) + create_user(arguments, response) @@ -242,6 +255,100 @@ class AUser(_UserBase): +class AddressUser(_UserBase): + """The user linked to an address.""" + + def __init__(self, address): + self._address = address + self._user = address.user + + def on_get(self, request, response): + """Return a single user end-point.""" + if self._user is None: + not_found(response) + else: + okay(response, self._resource_as_json(self._user)) + + def on_delete(self, request, response): + """Delete the named user, all her memberships, and addresses.""" + if self._user is None: + not_found(response) + return + self._user.unlink(self._address) + no_content(response) + + def on_post(self, request, response): + """Link a user to the address, and create it if needed.""" + if self._user: + conflict(response) + return + # When creating a linked user by POSTing, the user either must already + # exist, or it can be automatically created, if the auto_create flag + # is given and true (if missing, it defaults to true). However, in + # this case we do not accept 'email' as a POST field. + fields = CREATION_FIELDS.copy() + del fields['email'] + fields['user_id'] = int + fields['auto_create'] = as_boolean + fields['_optional'] = fields['_optional'] + ('user_id', 'auto_create') + try: + validator = Validator(**fields) + arguments = validator(request) + except ValueError as error: + bad_request(response, str(error)) + return + user_manager = getUtility(IUserManager) + if 'user_id' in arguments: + raw_uid = arguments['user_id'] + user_id = UUID(int=raw_uid) + user = user_manager.get_user_by_id(user_id) + if user is None: + not_found(response, b'No user with ID {}'.format(raw_uid)) + return + okay(response) + else: + auto_create = arguments.pop('auto_create', True) + if auto_create: + # This sets the 201 or 400 status. + user = create_user(arguments, response) + if user is None: + return + else: + forbidden(response) + return + user.link(self._address) + + def on_put(self, request, response): + """Set or replace the addresses's user.""" + if self._user: + self._user.unlink(self._address) + # Process post data and check for an existing user. + fields = CREATION_FIELDS.copy() + fields['user_id'] = int + fields['_optional'] = fields['_optional'] + ('user_id', 'email') + try: + validator = Validator(**fields) + arguments = validator(request) + except ValueError as error: + bad_request(response, str(error)) + return + user_manager = getUtility(IUserManager) + if 'user_id' in arguments: + raw_uid = arguments['user_id'] + user_id = UUID(int=raw_uid) + user = user_manager.get_user_by_id(user_id) + if user is None: + not_found(response, b'No user with ID {}'.format(raw_uid)) + return + okay(response) + else: + user = create_user(arguments, response) + if user is None: + return + user.link(self._address) + + + class Login: """<api>/users/<uid>/login""" diff --git a/src/mailman/rules/approved.py b/src/mailman/rules/approved.py index 2839ffef4..3b40d5dc9 100644 --- a/src/mailman/rules/approved.py +++ b/src/mailman/rules/approved.py @@ -71,9 +71,10 @@ class Approved: # Find the first text/plain part in the message part = None stripped = False + payload = None for part in typed_subpart_iterator(msg, 'text', 'plain'): + payload = part.get_payload(decode=True) break - payload = part.get_payload(decode=True) if payload is not None: charset = part.get_content_charset('us-ascii') payload = payload.decode(charset, 'replace') diff --git a/src/mailman/rules/implicit_dest.py b/src/mailman/rules/implicit_dest.py index 28effc490..0bc229b15 100644 --- a/src/mailman/rules/implicit_dest.py +++ b/src/mailman/rules/implicit_dest.py @@ -74,7 +74,7 @@ class ImplicitDestination: for header in ('to', 'cc', 'resent-to', 'resent-cc'): for fullname, address in getaddresses(msg.get_all(header, [])): if isinstance(address, bytes): - address = address.decode("ascii") + address = address.decode('ascii') address = address.lower() if address in aliases: return False diff --git a/src/mailman/rules/tests/test_approved.py b/src/mailman/rules/tests/test_approved.py index e7f122410..9976d4eff 100644 --- a/src/mailman/rules/tests/test_approved.py +++ b/src/mailman/rules/tests/test_approved.py @@ -491,3 +491,35 @@ deprecated = roundup_plaintext self.assertFalse(result) self.assertEqual(self._mlist.moderator_password, b'{plaintext}super secret') + + +class TestApprovedNoTextPlainPart(unittest.TestCase): + """Test the approved handler with HTML-only messages.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + self._rule = approved.Approved() + + def test_no_text_plain_part(self): + # When the message body only contains HTML, the rule should not throw + # AttributeError: 'NoneType' object has no attribute 'get_payload' + # LP: #1158721 + msg = mfs("""\ +From: anne@example.com +To: test@example.com +Subject: HTML only email +Message-ID: <ant> +MIME-Version: 1.0 +Content-Type: text/html; charset="Windows-1251" +Content-Transfer-Encoding: 7bit + +<HTML> +<BODY> +<P>This message contains only HTML, no plain/text part</P> +</BODY> +</HTML> +""") + result = self._rule.check(self._mlist, msg, {}) + self.assertFalse(result) diff --git a/src/mailman/utilities/email.py b/src/mailman/utilities/email.py index ee23bf9e8..ea44ad0a4 100644 --- a/src/mailman/utilities/email.py +++ b/src/mailman/utilities/email.py @@ -63,7 +63,7 @@ def add_message_hash(msg): if message_id is None: return if isinstance(message_id, bytes): - message_id = message_id.decode("ascii") + message_id = message_id.decode('ascii') # The angle brackets are not part of the Message-ID. See RFC 2822 # and http://wiki.list.org/display/DEV/Stable+URLs if message_id.startswith('<') and message_id.endswith('>'): |
