diff options
Diffstat (limited to 'src')
25 files changed, 750 insertions, 137 deletions
diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py index 0f39e611e..4f3fd187a 100644 --- a/src/mailman/app/bounces.py +++ b/src/mailman/app/bounces.py @@ -200,10 +200,13 @@ def send_probe(member, msg): optionsurl=member.options_url, owneraddr=mlist.owner_address, ) + message_id = msg['message-id'] + if isinstance(message_id, bytes): + message_id = message_id.decode('ascii') pendable = _ProbePendable( # We can only pend unicodes. member_id=member.member_id.hex, - message_id=msg['message-id'], + message_id=message_id, ) token = getUtility(IPendings).add(pendable) mailbox, domain_parts = split_email(mlist.bounces_address) diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py index dc5cabd0a..e5fbc9044 100644 --- a/src/mailman/app/moderator.py +++ b/src/mailman/app/moderator.py @@ -31,7 +31,6 @@ __all__ = [ ] -import six import time import logging @@ -87,8 +86,8 @@ def hold_message(mlist, msg, msgdata=None, reason=None): message_id = msg.get('message-id') if message_id is None: msg['Message-ID'] = message_id = make_msgid() - assert isinstance(message_id, six.text_type), ( - 'Message-ID is not a unicode: %s' % message_id) + 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 140cd0728..e6b43b530 100644 --- a/src/mailman/archiving/mailarchive.py +++ b/src/mailman/archiving/mailarchive.py @@ -66,6 +66,8 @@ class MailArchive: message_id_hash = msg.get('x-message-id-hash') if message_id_hash is None: return None + if isinstance(message_id_hash, bytes): + 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 47113108b..b50ceaf51 100644 --- a/src/mailman/archiving/mhonarc.py +++ b/src/mailman/archiving/mhonarc.py @@ -72,6 +72,8 @@ class MHonArc: message_id_hash = msg.get('x-message-id-hash') if message_id_hash is None: return None + if isinstance(message_id_hash, bytes): + 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 e564b40b1..3085f5700 100644 --- a/src/mailman/archiving/prototype.py +++ b/src/mailman/archiving/prototype.py @@ -67,6 +67,8 @@ class Prototype: message_id_hash = msg.get('x-message-id-hash') if message_id_hash is None: return None + if isinstance(message_id_hash, bytes): + 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 617807783..c56b14041 100644 --- a/src/mailman/commands/eml_membership.py +++ b/src/mailman/commands/eml_membership.py @@ -72,6 +72,8 @@ used. print(_('$self.name: No valid address found to subscribe'), file=results) return ContinueProcessing.no + if isinstance(address, bytes): + 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/core/tests/test_runner.py b/src/mailman/core/tests/test_runner.py index 66111234e..3ebddd7cc 100644 --- a/src/mailman/core/tests/test_runner.py +++ b/src/mailman/core/tests/test_runner.py @@ -31,9 +31,11 @@ from mailman.app.lifecycle import create_list from mailman.config import config from mailman.core.runner import Runner from mailman.interfaces.runner import RunnerCrashEvent +from mailman.runners.virgin import VirginRunner from mailman.testing.helpers import ( - configuration, event_subscribers, get_queue_messages, - make_testable_runner, specialized_message_from_string as mfs) + LogFileMark, configuration, event_subscribers, get_queue_messages, + make_digest_messages, make_testable_runner, + specialized_message_from_string as mfs) from mailman.testing.layers import ConfigLayer @@ -87,3 +89,37 @@ Message-ID: <ant> shunted = get_queue_messages('shunt') self.assertEqual(len(shunted), 1) self.assertEqual(shunted[0].msg['message-id'], '<ant>') + + def test_digest_messages(self): + # In LP: #1130697, the digest runner creates MIME digests using the + # stdlib MIMEMutlipart class, however this class does not have the + # extended attributes we require (e.g. .sender). The fix is to use a + # subclass of MIMEMultipart and our own Message subclass; this adds + # back the required attributes. (LP: #1130696) + # + # Start by creating the raw ingredients for the digests. This also + # runs the digest runner, thus producing the digest messages into the + # virgin queue. + make_digest_messages(self._mlist) + # Run the virgin queue processor, which runs the cook-headers and + # to-outgoing handlers. This should produce no error. + error_log = LogFileMark('mailman.error') + runner = make_testable_runner(VirginRunner, 'virgin') + runner.run() + error_text = error_log.read() + self.assertEqual(len(error_text), 0, error_text) + self.assertEqual(len(get_queue_messages('shunt')), 0) + messages = get_queue_messages('out') + self.assertEqual(len(messages), 2) + # Which one is the MIME digest? + mime_digest = None + for bag in messages: + if bag.msg.get_content_type() == 'multipart/mixed': + assert mime_digest is None, 'Found two MIME digests' + mime_digest = bag.msg + # The cook-headers handler ran. + self.assertIn('x-mailman-version', mime_digest) + self.assertEqual(mime_digest['precedence'], 'list') + # The list's -request address is the original sender. + self.assertEqual(bag.msgdata['original_sender'], + 'test-request@example.com') diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index ac81cd386..9eb7c0832 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -16,9 +16,15 @@ 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) + * Fixed `AttributeError` on MIME digest messages. Given by Aurélien Bompard. + (LP: #1130696) Commands -------- @@ -38,6 +44,7 @@ Database Development ----------- + * Python 3.4 is now the minimum requirement. * You no longer have to create a virtual environment separately when running the test suite. Just use `tox`. * You no longer have to edit `src/mailman/testing/testing.cfg` to run the @@ -61,6 +68,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 92f5ff846..d77afcbe0 100644 --- a/src/mailman/email/message.py +++ b/src/mailman/email/message.py @@ -28,6 +28,7 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ 'Message', + 'MultipartDigestMessage', 'OwnerNotification', 'UserNotification', ] @@ -38,6 +39,7 @@ import email.message import email.utils from email.header import Header +from email.mime.multipart import MIMEMultipart from mailman.config import config @@ -53,29 +55,6 @@ class Message(email.message.Message): self.__version__ = VERSION email.message.Message.__init__(self) - def __getitem__(self, key): - # Ensure that header values are unicodes. - value = email.message.Message.__getitem__(self, key) - if isinstance(value, bytes): - return value.decode('ascii') - return value - - def get(self, name, failobj=None): - # Ensure that header values are unicodes. - value = email.message.Message.get(self, name, failobj) - if isinstance(value, bytes): - return value.decode('ascii') - return value - - def get_all(self, name, failobj=None): - # Ensure all header values are unicodes. - missing = object() - all_values = email.message.Message.get_all(self, name, missing) - if all_values is missing: - return failobj - return [(value.decode('ascii') if isinstance(value, bytes) else value) - for value in all_values] - # BAW: For debugging w/ bin/dumpdb. Apparently pprint uses repr. def __repr__(self): return self.__str__() @@ -144,18 +123,20 @@ class Message(email.message.Message): field_values = self.get_all(header, []) senders.extend(address.lower() for (display_name, address) in email.utils.getaddresses(field_values)) - # Filter out None and the empty string. - return [sender for sender in senders if sender] + # Filter out None and the empty string, and convert to unicode. + clean_senders = [] + for sender in senders: + if not sender: + continue + if isinstance(sender, bytes): + sender = sender.decode('ascii') + clean_senders.append(sender) + return clean_senders - def get_filename(self, failobj=None): - """Some MUA have bugs in RFC2231 filename encoding and cause - Mailman to stop delivery in Scrubber.py (called from ToDigest.py). - """ - try: - filename = email.message.Message.get_filename(self, failobj) - return filename - except (UnicodeError, LookupError, ValueError): - return failobj + + +class MultipartDigestMessage(MIMEMultipart, Message): + """Mix-in class for MIME digest messages.""" diff --git a/src/mailman/email/tests/test_message.py b/src/mailman/email/tests/test_message.py index e281c0d06..280a86477 100644 --- a/src/mailman/email/tests/test_message.py +++ b/src/mailman/email/tests/test_message.py @@ -22,13 +22,15 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ 'TestMessage', + 'TestMessageSubclass', ] import unittest +from email.parser import FeedParser from mailman.app.lifecycle import create_list -from mailman.email.message import UserNotification +from mailman.email.message import Message, UserNotification from mailman.testing.helpers import get_queue_messages from mailman.testing.layers import ConfigLayer @@ -56,5 +58,36 @@ 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("""\ +Message-ID: <blah@example.com> +Content-Type: multipart/mixed; boundary="------------050607040206050605060208" + +This is a multi-part message in MIME format. +--------------050607040206050605060208 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +Test message containing an attachment with an accented filename + +--------------050607040206050605060208 +Content-Disposition: attachment; + filename*=UTF-8''d%C3%A9jeuner.txt + +Test content +--------------050607040206050605060208-- +""") + msg = parser.close() + attachment = msg.get_payload(1) + try: + filename = attachment.get_filename() + except TypeError as e: + self.fail(e) + self.assertEqual(filename, u'd\xe9jeuner.txt') diff --git a/src/mailman/handlers/tests/test_cook_headers.py b/src/mailman/handlers/tests/test_cook_headers.py new file mode 100644 index 000000000..d83a44f20 --- /dev/null +++ b/src/mailman/handlers/tests/test_cook_headers.py @@ -0,0 +1,55 @@ +# Copyright (C) 2014 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 the cook_headers handler.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + 'TestCookHeaders', + ] + + +import unittest + +from mailman.app.lifecycle import create_list +from mailman.handlers import cook_headers +from mailman.testing.helpers import get_queue_messages, make_digest_messages +from mailman.testing.layers import ConfigLayer + + + +class TestCookHeaders(unittest.TestCase): + """Test the cook_headers handler.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + + def test_process_digest(self): + # MIME digests messages are multiparts. + make_digest_messages(self._mlist) + messages = [bag.msg for bag in get_queue_messages('virgin')] + self.assertEqual(len(messages), 2) + for msg in messages: + try: + cook_headers.process(self._mlist, msg, {}) + except AttributeError as e: + # LP: #1130696 would raise an AttributeError on .sender + self.fail(e) diff --git a/src/mailman/model/bounce.py b/src/mailman/model/bounce.py index cd658052d..26ebbe0c6 100644 --- a/src/mailman/model/bounce.py +++ b/src/mailman/model/bounce.py @@ -57,7 +57,10 @@ class BounceEvent(Model): self.list_id = list_id self.email = email self.timestamp = now() - self.message_id = msg['message-id'] + msgid = msg['message-id'] + if isinstance(msgid, bytes): + 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 459cc116f..8dbe19b80 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 @@ -57,6 +58,8 @@ class MessageStore: raise ValueError('Exactly one Message-ID header required') # Calculate and insert the X-Message-ID-Hash. message_id = message_ids[0] + if isinstance(message_id, bytes): + 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 7923d8bf9..ce2aa4288 100644 --- a/src/mailman/rest/addresses.py +++ b/src/mailman/rest/addresses.py @@ -64,6 +64,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): @@ -158,6 +161,15 @@ 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 670a12ef5..bcffd6830 100644 --- a/src/mailman/rest/docs/addresses.rst +++ b/src/mailman/rest/docs/addresses.rst @@ -137,11 +137,124 @@ Now Cris's address is unverified. self_link: http://localhost:9001/3.0/addresses/cris@example.com +The user +======== + +To link an address to a user, a POST request can be sent to the ``/user`` +sub-resource of the address. If the user does not exist, it will be created. + + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com/user', + ... {'display_name': 'Cris X. Person'}) + content-length: 0 + date: ... + location: http://localhost:9001/3.0/users/1 + server: ... + status: 201 + +The user is now created and the address is linked to it: + + >>> cris.user + <User "Cris X. Person" (1) at 0x...> + >>> cris_user = user_manager.get_user('cris@example.com') + >>> cris_user + <User "Cris X. Person" (1) at 0x...> + >>> cris.user == cris_user + True + >>> [a.email for a in cris_user.addresses] + ['cris@example.com'] + +A link to the user resource is now available as a sub-resource. + + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com') + display_name: Cris Person + email: cris@example.com + http_etag: "..." + original_email: cris@example.com + registered_on: 2005-08-01T07:49:23 + self_link: http://localhost:9001/3.0/addresses/cris@example.com + user: http://localhost:9001/3.0/users/1 + +To prevent automatic user creation from taking place, add the `auto_create` +parameter to the POST request and set it to a false-equivalent value like 0: + + >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com/user', + ... {'display_name': 'Anne User', 'auto_create': 0}) + Traceback (most recent call last): + ... + urllib.error.HTTPError: HTTP Error 403: ... + +A request to the `/user` sub-resource will return the linked user's +representation: + + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com/user') + created_on: 2005-08-01T07:49:23 + display_name: Cris X. Person + http_etag: "..." + password: ... + self_link: http://localhost:9001/3.0/users/1 + user_id: 1 + +The address and the user can be unlinked by sending a DELETE request on the +`/user` resource. The user itself is not deleted, only the link. + + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com/user', + ... method='DELETE') + content-length: 0 + date: ... + server: ... + status: 204 + >>> transaction.abort() + >>> cris.user == None + True + >>> from uuid import UUID + >>> user_manager.get_user_by_id(UUID(int=1)) + <User "Cris X. Person" (1) at 0x...> + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com/user') + Traceback (most recent call last): + ... + urllib.error.HTTPError: HTTP Error 404: ... + +You can link an existing user to an address by passing the user's ID in the +POST request. + + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com/user', + ... {'user_id': 1}) + content-length: 0 + date: ... + server: ... + status: 200 + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com/user') + created_on: ... + display_name: Cris X. Person + http_etag: ... + password: ... + self_link: http://localhost:9001/3.0/users/1 + user_id: 1 + +To link an address to a different user, you can either send a DELETE request +followed by a POST request, or you can send a PUT request. + + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com/user', + ... {'display_name': 'Cris Q Person'}, method="PUT") + content-length: 0 + date: ... + location: http://localhost:9001/3.0/users/2 + server: ... + status: 201 + >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com/user') + created_on: ... + display_name: Cris Q Person + http_etag: ... + password: ... + self_link: http://localhost:9001/3.0/users/2 + user_id: 2 + + User addresses ============== Users control addresses. The canonical URLs for these user-controlled -addresses live in the /addresses namespace. +addresses live in the ``/addresses`` namespace. :: >>> dave = user_manager.create_user('dave@example.com', 'Dave Person') @@ -154,6 +267,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/3 http_etag: "..." start: 0 total_size: 1 @@ -165,6 +279,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/3 A user can be associated with multiple email addresses. You can add new addresses to an existing user. @@ -201,6 +316,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/3 entry 1: display_name: Dave Person email: dave@example.com @@ -208,6 +324,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/3 entry 2: display_name: Davie P email: dp@example.org @@ -215,6 +332,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/3 http_etag: "..." start: 0 total_size: 3 @@ -261,7 +379,7 @@ Elle can get her memberships for each of her email addresses. list_id: ant.example.com role: member self_link: http://localhost:9001/3.0/members/1 - user: http://localhost:9001/3.0/users/2 + user: http://localhost:9001/3.0/users/4 entry 1: address: http://localhost:9001/3.0/addresses/elle@example.com delivery_mode: regular @@ -270,7 +388,7 @@ Elle can get her memberships for each of her email addresses. list_id: bee.example.com role: member self_link: http://localhost:9001/3.0/members/2 - user: http://localhost:9001/3.0/users/2 + user: http://localhost:9001/3.0/users/4 http_etag: "..." start: 0 total_size: 2 @@ -300,7 +418,7 @@ does not show up in the list of memberships for his other address. list_id: ant.example.com role: member self_link: http://localhost:9001/3.0/members/1 - user: http://localhost:9001/3.0/users/2 + user: http://localhost:9001/3.0/users/4 entry 1: address: http://localhost:9001/3.0/addresses/elle@example.com delivery_mode: regular @@ -309,7 +427,7 @@ does not show up in the list of memberships for his other address. list_id: bee.example.com role: member self_link: http://localhost:9001/3.0/members/2 - user: http://localhost:9001/3.0/users/2 + user: http://localhost:9001/3.0/users/4 http_etag: "..." start: 0 total_size: 2 @@ -324,7 +442,7 @@ does not show up in the list of memberships for his other address. list_id: bee.example.com role: member self_link: http://localhost:9001/3.0/members/3 - user: http://localhost:9001/3.0/users/2 + user: http://localhost:9001/3.0/users/4 http_etag: "..." start: 0 total_size: 1 diff --git a/src/mailman/rest/docs/users.rst b/src/mailman/rest/docs/users.rst index dcebba3e6..824492333 100644 --- a/src/mailman/rest/docs/users.rst +++ b/src/mailman/rest/docs/users.rst @@ -308,18 +308,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 @@ -327,6 +330,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 ea850da9b..5c70fad97 100644 --- a/src/mailman/rest/tests/test_addresses.py +++ b/src/mailman/rest/tests/test_addresses.py @@ -211,3 +211,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 c8e3475e7..a1c46bc52 100644 --- a/src/mailman/rest/users.py +++ b/src/mailman/rest/users.py @@ -27,8 +27,7 @@ __all__ = [ ] -import six - +from lazr.config import as_boolean from passlib.utils import generate_password as generate from uuid import UUID from zope.component import getUtility @@ -41,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 @@ -60,11 +60,40 @@ class PasswordEncrypterGetterSetter(GetterSetter): ATTRIBUTES = dict( - display_name=GetterSetter(six.text_type), + display_name=GetterSetter(str), cleartext_password=PasswordEncrypterGetterSetter(), ) +CREATION_FIELDS = dict( + email=str, + display_name=str, + password=str, + _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, '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.""" @@ -79,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. @@ -107,30 +136,12 @@ class AllUsers(_UserBase): def on_post(self, request, response): """Create a new user.""" try: - validator = Validator(email=six.text_type, - display_name=six.text_type, - password=six.text_type, - _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: - reason = 'Address already exists: {}'.format(error.address) - bad_request(response, reason.encode('utf-8')) - 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) @@ -244,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""" @@ -255,7 +360,7 @@ class Login: # We do not want to encrypt the plaintext password given in the POST # data. That would hash the password, but we need to have the # plaintext in order to pass into passlib. - validator = Validator(cleartext_password=GetterSetter(six.text_type)) + validator = Validator(cleartext_password=GetterSetter(str)) try: values = validator(request) except ValueError as error: diff --git a/src/mailman/rules/approved.py b/src/mailman/rules/approved.py index c5d099001..054bb1e3d 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 8bfb0d2e0..0bc229b15 100644 --- a/src/mailman/rules/implicit_dest.py +++ b/src/mailman/rules/implicit_dest.py @@ -73,6 +73,8 @@ class ImplicitDestination: recipients = set() 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.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 ec8e861af..00c556069 100644 --- a/src/mailman/rules/tests/test_approved.py +++ b/src/mailman/rules/tests/test_approved.py @@ -491,3 +491,36 @@ deprecated = roundup_plaintext self.assertFalse(result) self.assertEqual(self._mlist.moderator_password, '{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/runners/digest.py b/src/mailman/runners/digest.py index f3b94841b..0a13a3c49 100644 --- a/src/mailman/runners/digest.py +++ b/src/mailman/runners/digest.py @@ -30,14 +30,13 @@ import logging from copy import deepcopy from email.header import Header -from email.message import Message from email.mime.message import MIMEMessage -from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formatdate, getaddresses, make_msgid from mailman.config import config from mailman.core.i18n import _ from mailman.core.runner import Runner +from mailman.email.message import Message, MultipartDigestMessage from mailman.handlers.decorate import decorate from mailman.interfaces.member import DeliveryMode, DeliveryStatus from mailman.utilities.i18n import make @@ -170,7 +169,7 @@ class MIMEDigester(Digester): self._keepers = set(config.digests.mime_digest_keep_headers.split()) def _make_message(self): - return MIMEMultipart('mixed') + return MultipartDigestMessage('mixed') def add_toc(self, count): """Add the table of contents.""" diff --git a/src/mailman/runners/tests/test_digest.py b/src/mailman/runners/tests/test_digest.py index bd32050fb..8b19188df 100644 --- a/src/mailman/runners/tests/test_digest.py +++ b/src/mailman/runners/tests/test_digest.py @@ -26,18 +26,21 @@ __all__ = [ ] -import os import unittest +from email.iterators import _structure as structure from email.mime.text import MIMEText +from io import StringIO from mailman.app.lifecycle import create_list from mailman.config import config from mailman.email.message import Message from mailman.runners.digest import DigestRunner from mailman.testing.helpers import ( - LogFileMark, digest_mbox, get_queue_messages, make_testable_runner, + LogFileMark, digest_mbox, get_queue_messages, make_digest_messages, + make_testable_runner, message_from_string, specialized_message_from_string as mfs) from mailman.testing.layers import ConfigLayer +from string import Template @@ -45,6 +48,7 @@ class TestDigest(unittest.TestCase): """Test the digest runner.""" layer = ConfigLayer + maxDiff = None def setUp(self): self._mlist = create_list('test@example.com') @@ -55,23 +59,9 @@ class TestDigest(unittest.TestCase): self._runner = make_testable_runner(DigestRunner, 'digest') self._process = config.handlers['to-digest'].process - def test_simple_message(self): - msg = mfs("""\ -From: anne@example.org -To: test@example.com - -message triggering a digest -""") - mbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') - self._process(self._mlist, msg, {}) - self._digestq.enqueue( - msg, - listid=self._mlist.list_id, - digest_path=mbox_path, - volume=1, digest_number=1) - self._runner.run() - # There are two messages in the virgin queue: the digest as plain-text - # and as multipart. + def _check_virgin_queue(self): + # There should be two messages in the virgin queue: the digest as + # plain-text and as multipart. messages = get_queue_messages('virgin') self.assertEqual(len(messages), 2) self.assertEqual( @@ -81,6 +71,10 @@ message triggering a digest self.assertEqual(item.msg['subject'], 'Test Digest, Vol 1, Issue 1') + def test_simple_message(self): + make_digest_messages(self._mlist) + self._check_virgin_queue() + def test_non_ascii_message(self): msg = Message() msg['From'] = 'anne@example.org' @@ -89,28 +83,65 @@ message triggering a digest msg.attach(MIMEText('message with non-ascii chars: \xc3\xa9', 'plain', 'utf-8')) mbox = digest_mbox(self._mlist) - mbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf') mbox.add(msg.as_string()) - self._digestq.enqueue( - msg, - listid=self._mlist.list_id, - digest_path=mbox_path, - volume=1, digest_number=1) # Use any error logs as the error message if the test fails. error_log = LogFileMark('mailman.error') - self._runner.run() + make_digest_messages(self._mlist, msg) # The runner will send the file to the shunt queue on exception. self.assertEqual(len(self._shuntq.files), 0, error_log.read()) - # There are two messages in the virgin queue: the digest as plain-text - # and as multipart. - messages = get_queue_messages('virgin') - self.assertEqual(len(messages), 2) - self.assertEqual( - sorted(item.msg.get_content_type() for item in messages), - ['multipart/mixed', 'text/plain']) - for item in messages: - self.assertEqual(item.msg['subject'], - 'Test Digest, Vol 1, Issue 1') + self._check_virgin_queue() + + def test_mime_digest_format(self): + # Make sure that the format of the MIME digest is as expected. + self._mlist.digest_size_threshold = 0.6 + self._mlist.volume = 1 + self._mlist.next_digest_number = 1 + self._mlist.send_welcome_message = False + # Fill the digest. + process = config.handlers['to-digest'].process + size = 0 + for i in range(1, 5): + text = Template("""\ +From: aperson@example.com +To: xtest@example.com +Subject: Test message $i +List-Post: <test@example.com> + +Here is message $i +""").substitute(i=i) + msg = message_from_string(text) + process(self._mlist, msg, {}) + size += len(text) + if size >= self._mlist.digest_size_threshold * 1024: + break + # Run the digest runner to create the MIME and RFC 1153 digests. + runner = make_testable_runner(DigestRunner) + runner.run() + items = get_queue_messages('virgin') + self.assertEqual(len(items), 2) + # Find the MIME one. + mime_digest = None + for item in items: + if item.msg.is_multipart(): + assert mime_digest is None, 'We got two MIME digests' + mime_digest = item.msg + fp = StringIO() + # Verify the structure is what we expect. + structure(mime_digest, fp) + self.assertMultiLineEqual(fp.getvalue(), """\ +multipart/mixed + text/plain + text/plain + message/rfc822 + text/plain + message/rfc822 + text/plain + message/rfc822 + text/plain + message/rfc822 + text/plain + text/plain +""") diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py index 1f68e6975..1b8f0d7af 100644 --- a/src/mailman/testing/helpers.py +++ b/src/mailman/testing/helpers.py @@ -22,7 +22,6 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ 'LogFileMark', - 'PrettyEmailPolicy', 'TestableMaster', 'call_api', 'chdir', @@ -32,6 +31,7 @@ __all__ = [ 'get_lmtp_client', 'get_nntp_server', 'get_queue_messages', + 'make_digest_messages', 'make_testable_runner', 'reset_the_world', 'specialized_message_from_string', @@ -62,11 +62,9 @@ from httplib2 import Http from lazr.config import as_timedelta from six.moves.urllib_error import HTTPError from six.moves.urllib_parse import urlencode -from unittest.mock import patch from zope import event from zope.component import getUtility -from email.policy import Compat32 from mailman.bin.master import Loop as Master from mailman.config import config from mailman.database.transaction import transaction @@ -75,6 +73,7 @@ from mailman.interfaces.member import MemberRole from mailman.interfaces.messages import IMessageStore from mailman.interfaces.styles import IStyleManager from mailman.interfaces.usermanager import IUserManager +from mailman.runners.digest import DigestRunner from mailman.utilities.mailbox import Mailbox @@ -538,22 +537,21 @@ class LogFileMark: -def _pretty(self, *args, **kws): - return str(self) +def make_digest_messages(mlist, msg=None): + if msg is None: + msg = specialized_message_from_string("""\ +From: anne@example.org +To: {listname} +Message-ID: <testing> - -class PrettyEmailPolicy(Compat32): - """Horrible hack to make mailman/runners/docs/digester.rst work. - - Back in Python 2 days, the i18n'd headers printed in digester.rst used the - full unicode string version, instead of the RFC 2047 encoded headers. - It's more correct to use the RFC 2047 headers, but it's also uglier in a - doctest, so to port the doctest to Python 3, we use this email policy hack - to get the headers printed as (unicode) strings instead of RFC 2047 - encoded headers. - """ - # This will hurt your eyeballs. It relies on the specific implementation - # of Compat32 and it *will* break if that class is refactored. - @patch('email.header.Header.encode', _pretty) - def _fold(self, name, value, sanitize): - return super()._fold(name, value, sanitize) +message triggering a digest +""".format(listname=mlist.fqdn_listname)) + mbox_path = os.path.join(mlist.data_path, 'digest.mmdf') + config.handlers['to-digest'].process(mlist, msg, {}) + config.switchboards['digest'].enqueue( + msg, + listname=mlist.fqdn_listname, + digest_path=mbox_path, + volume=1, digest_number=1) + runner = make_testable_runner(DigestRunner, 'digest') + runner.run() diff --git a/src/mailman/utilities/email.py b/src/mailman/utilities/email.py index 555fa769e..0237042c7 100644 --- a/src/mailman/utilities/email.py +++ b/src/mailman/utilities/email.py @@ -62,6 +62,8 @@ def add_message_hash(msg): message_id = msg.get('message-id') if message_id is None: return + if isinstance(message_id, bytes): + 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('>'): |
