summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mailman/app/bounces.py5
-rw-r--r--src/mailman/app/moderator.py5
-rw-r--r--src/mailman/archiving/mailarchive.py2
-rw-r--r--src/mailman/archiving/mhonarc.py2
-rw-r--r--src/mailman/archiving/prototype.py2
-rw-r--r--src/mailman/commands/eml_membership.py2
-rw-r--r--src/mailman/core/tests/test_runner.py40
-rw-r--r--src/mailman/docs/NEWS.rst15
-rw-r--r--src/mailman/email/message.py49
-rw-r--r--src/mailman/email/tests/test_message.py37
-rw-r--r--src/mailman/handlers/tests/test_cook_headers.py55
-rw-r--r--src/mailman/model/bounce.py5
-rw-r--r--src/mailman/model/messagestore.py3
-rw-r--r--src/mailman/rest/addresses.py12
-rw-r--r--src/mailman/rest/docs/addresses.rst130
-rw-r--r--src/mailman/rest/docs/users.rst4
-rw-r--r--src/mailman/rest/tests/test_addresses.py174
-rw-r--r--src/mailman/rest/users.py157
-rw-r--r--src/mailman/rules/approved.py3
-rw-r--r--src/mailman/rules/implicit_dest.py2
-rw-r--r--src/mailman/rules/tests/test_approved.py33
-rw-r--r--src/mailman/runners/digest.py5
-rw-r--r--src/mailman/runners/tests/test_digest.py103
-rw-r--r--src/mailman/testing/helpers.py40
-rw-r--r--src/mailman/utilities/email.py2
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('>'):