=== modified file '.bzrignore'
--- .bzrignore	2014-11-08 15:27:56 +0000
+++ .bzrignore	2014-12-22 17:38:14 +0000
@@ -22,3 +22,4 @@
 .coverage
 htmlcov
 .tox
+__pycache__

=== modified file 'setup.py'
--- setup.py	2014-11-02 19:55:10 +0000
+++ setup.py	2014-12-22 17:38:14 +0000
@@ -105,6 +105,7 @@
         'mock',
         'nose2',
         'passlib',
+        'six',
         'sqlalchemy',
         'zope.component',
         'zope.configuration',

=== modified file 'src/mailman/app/bounces.py'
--- src/mailman/app/bounces.py	2014-12-09 01:38:26 +0000
+++ src/mailman/app/bounces.py	2014-12-22 17:38:14 +0000
@@ -71,8 +71,8 @@
     :type error: Exception
     """
     # Bounce a message back to the sender, with an error message if provided
-    # in the exception argument.
-    if msg.sender is None:
+    # in the exception argument.  .sender might be None or the empty string.
+    if not msg.sender:
         # We can't bounce the message if we don't know who it's supposed to go
         # to.
         return

=== modified file 'src/mailman/app/docs/hooks.rst'
--- src/mailman/app/docs/hooks.rst	2014-11-09 12:52:58 +0000
+++ src/mailman/app/docs/hooks.rst	2014-12-22 17:38:14 +0000
@@ -18,12 +18,12 @@
     ... counter = 1
     ... def pre_hook():
     ...     global counter
-    ...     print 'pre-hook:', counter
+    ...     print('pre-hook:', counter)
     ...     counter += 1
     ...
     ... def post_hook():
     ...     global counter
-    ...     print 'post-hook:', counter
+    ...     print('post-hook:', counter)
     ...     counter += 1
     ... """, file=fp)
     >>> fp.close()
@@ -61,6 +61,7 @@
     ...     proc = subprocess.Popen(
     ...         [exe, 'lists', '--domain', 'ignore', '-q'],
     ...         cwd=ConfigLayer.root_directory, env=env,
+    ...         universal_newlines=True,
     ...         stdout=subprocess.PIPE, stderr=subprocess.PIPE)
     ...     stdout, stderr = proc.communicate()
     ...     assert proc.returncode == 0, stderr

=== modified file 'src/mailman/app/docs/pipelines.rst'
--- src/mailman/app/docs/pipelines.rst	2014-11-08 15:14:00 +0000
+++ src/mailman/app/docs/pipelines.rst	2014-12-22 17:38:14 +0000
@@ -45,9 +45,9 @@
     To: test@example.com
     Message-ID: <first>
     X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
-    Subject: [Test] My first post
     X-Mailman-Version: ...
     Precedence: list
+    Subject: [Test] My first post
     List-Id: <test.example.com>
     Archived-At: http://lists.example.com/.../4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
     List-Archive: <http://lists.example.com/archives/test@example.com>
@@ -67,7 +67,7 @@
     >>> dump_msgdata(msgdata)
     original_sender : aperson@example.com
     original_subject: My first post
-    recipients      : set([])
+    recipients      : set()
     stripped_subject: My first post
 
 After pipeline processing, the message is now sitting in various other
@@ -84,9 +84,9 @@
     To: test@example.com
     Message-ID: <first>
     X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
-    Subject: [Test] My first post
     X-Mailman-Version: ...
     Precedence: list
+    Subject: [Test] My first post
     List-Id: <test.example.com>
     ...
     <BLANKLINE>
@@ -97,7 +97,7 @@
     _parsemsg       : False
     original_sender : aperson@example.com
     original_subject: My first post
-    recipients      : set([])
+    recipients      : set()
     stripped_subject: My first post
     version         : 3
 
@@ -121,9 +121,9 @@
     To: test@example.com
     Message-ID: <first>
     X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
-    Subject: [Test] My first post
     X-Mailman-Version: ...
     Precedence: list
+    Subject: [Test] My first post
     List-Id: <test.example.com>
     ...
     <BLANKLINE>
@@ -132,10 +132,10 @@
 
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg       : False
-    listname        : test@example.com
+    listid          : test.example.com
     original_sender : aperson@example.com
     original_subject: My first post
-    recipients      : set([])
+    recipients      : set()
     stripped_subject: My first post
     version         : 3
 
@@ -152,9 +152,9 @@
     To: test@example.com
     Message-ID: <first>
     X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
-    Subject: [Test] My first post
     X-Mailman-Version: ...
     Precedence: list
+    Subject: [Test] My first post
     List-Id: <test.example.com>
     ...
     <BLANKLINE>

=== modified file 'src/mailman/app/docs/subscriptions.rst'
--- src/mailman/app/docs/subscriptions.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/app/docs/subscriptions.rst	2014-12-22 17:38:14 +0000
@@ -67,13 +67,6 @@
 email address.  However, the user must have a preferred email address.
 ::
 
-    >>> service.join('test.example.com', bart.user.user_id,
-    ...              role=MemberRole.owner)
-    Traceback (most recent call last):
-    ...
-    MissingPreferredAddressError: User must have a preferred address:
-        <User "Bart Person" (2) at ...>
-
     >>> from mailman.utilities.datetime import now
     >>> address = list(bart.user.addresses)[0]
     >>> address.verified_on = now()

=== modified file 'src/mailman/app/inject.py'
--- src/mailman/app/inject.py	2014-01-01 14:59:42 +0000
+++ src/mailman/app/inject.py	2014-12-22 17:38:14 +0000
@@ -66,7 +66,7 @@
         msg['Date'] = formatdate(localtime=True)
     msg.original_size = len(msg.as_string())
     msgdata = dict(
-        listname=mlist.fqdn_listname,
+        listid=mlist.list_id,
         original_size=msg.original_size,
         )
     msgdata.update(kws)

=== modified file 'src/mailman/app/moderator.py'
--- src/mailman/app/moderator.py	2014-12-09 01:38:26 +0000
+++ src/mailman/app/moderator.py	2014-12-22 17:38:14 +0000
@@ -31,12 +31,11 @@
     ]
 
 
+import six
 import time
 import logging
 
 from email.utils import formataddr, formatdate, getaddresses, make_msgid
-from zope.component import getUtility
-
 from mailman.app.membership import add_member, delete_member
 from mailman.app.notifications import send_admin_subscription_notice
 from mailman.config import config
@@ -51,6 +50,7 @@
 from mailman.interfaces.requests import IListRequests, RequestType
 from mailman.utilities.datetime import now
 from mailman.utilities.i18n import make
+from zope.component import getUtility
 
 
 NL = '\n'
@@ -86,14 +86,20 @@
     # Message-ID header.
     message_id = msg.get('message-id')
     if message_id is None:
+<<<<<<< TREE
         msg['Message-ID'] = message_id = make_msgid().decode('ascii')
     elif isinstance(message_id, bytes):
         message_id = message_id.decode('ascii')
+=======
+        msg['Message-ID'] = message_id = make_msgid()
+    assert isinstance(message_id, six.text_type), (
+        'Message-ID is not a unicode: %s' % message_id)
+>>>>>>> MERGE-SOURCE
     getUtility(IMessageStore).add(msg)
     # Prepare the message metadata with some extra information needed only by
     # the moderation interface.
     msgdata['_mod_message_id'] = message_id
-    msgdata['_mod_fqdn_listname'] = mlist.fqdn_listname
+    msgdata['_mod_listid'] = mlist.list_id
     msgdata['_mod_sender'] = msg.sender
     msgdata['_mod_subject'] = msg.get('subject', _('(no subject)'))
     msgdata['_mod_reason'] = reason
@@ -134,7 +140,7 @@
         # Start by getting the message from the message store.
         msg = message_store.get_message_by_id(message_id)
         # Delete moderation-specific entries from the message metadata.
-        for key in msgdata.keys():
+        for key in list(msgdata):
             if key.startswith('_mod_'):
                 del msgdata[key]
         # Add some metadata to indicate this message has now been approved.

=== modified file 'src/mailman/app/notifications.py'
--- src/mailman/app/notifications.py	2014-01-07 03:43:59 +0000
+++ src/mailman/app/notifications.py	2014-12-22 17:38:14 +0000
@@ -31,9 +31,6 @@
 
 from email.utils import formataddr
 from lazr.config import as_boolean
-from urllib2 import URLError
-from zope.component import getUtility
-
 from mailman.config import config
 from mailman.core.i18n import _
 from mailman.email.message import OwnerNotification, UserNotification
@@ -41,6 +38,8 @@
 from mailman.interfaces.templates import ITemplateLoader
 from mailman.utilities.i18n import make
 from mailman.utilities.string import expand, wrap
+from six.moves.urllib_error import URLError
+from zope.component import getUtility
 
 
 log = logging.getLogger('mailman.error')
@@ -141,7 +140,6 @@
     """
     with _.using(mlist.preferred_language.code):
         subject = _('$mlist.display_name subscription notification')
-    display_name = display_name.encode(language.charset, 'replace')
     text = make('adminsubscribeack.txt',
                 mailing_list=mlist,
                 listname=mlist.display_name,

=== modified file 'src/mailman/app/subscriptions.py'
--- src/mailman/app/subscriptions.py	2014-09-22 18:47:02 +0000
+++ src/mailman/app/subscriptions.py	2014-12-22 17:38:14 +0000
@@ -26,6 +26,8 @@
     ]
 
 
+import six
+
 from operator import attrgetter
 from passlib.utils import generate_password as generate
 from sqlalchemy import and_, or_
@@ -108,7 +110,7 @@
         # the parameter can either be an email address or a user id.
         query = []
         if subscriber is not None:
-            if isinstance(subscriber, basestring):
+            if isinstance(subscriber, six.text_type):
                 # subscriber is an email address.
                 address = user_manager.get_address(subscriber)
                 user = user_manager.get_user(subscriber)
@@ -148,7 +150,7 @@
         if mlist is None:
             raise NoSuchListError(list_id)
         # Is the subscriber an email address or user id?
-        if isinstance(subscriber, basestring):
+        if isinstance(subscriber, str):
             if display_name is None:
                 display_name, at, domain = subscriber.partition('@')
             # Because we want to keep the REST API simple, there is no

=== modified file 'src/mailman/app/templates.py'
--- src/mailman/app/templates.py	2014-04-14 16:14:13 +0000
+++ src/mailman/app/templates.py	2014-12-22 17:38:14 +0000
@@ -25,22 +25,22 @@
     ]
 
 
-import urllib2
-
 from contextlib import closing
-from urllib import addinfourl
-from urlparse import urlparse
-from zope.component import getUtility
-from zope.interface import implementer
-
-from mailman.utilities.i18n import TemplateNotFoundError, find
 from mailman.interfaces.languages import ILanguageManager
 from mailman.interfaces.listmanager import IListManager
 from mailman.interfaces.templates import ITemplateLoader
+from mailman.utilities.i18n import TemplateNotFoundError, find
+from six.moves.urllib_error import URLError
+from six.moves.urllib_parse import urlparse
+from six.moves.urllib_request import (
+    BaseHandler, build_opener, install_opener, urlopen)
+from six.moves.urllib_response import addinfourl
+from zope.component import getUtility
+from zope.interface import implementer
 
 
 
-class MailmanHandler(urllib2.BaseHandler):
+class MailmanHandler(BaseHandler):
     # Handle internal mailman: URLs.
     def mailman_open(self, req):
         # Parse urls of the form:
@@ -55,9 +55,9 @@
         assert parsed.scheme == 'mailman'
         # The path can contain one, two, or three components.  Since no empty
         # path components are legal, filter them out.
-        parts = filter(None, parsed.path.split('/'))
+        parts = [p for p in parsed.path.split('/') if p]
         if len(parts) == 0:
-            raise urllib2.URLError('No template specified')
+            raise URLError('No template specified')
         elif len(parts) == 1:
             template = parts[0]
         elif len(parts) == 2:
@@ -69,25 +69,25 @@
             language = getUtility(ILanguageManager).get(part0)
             mlist = getUtility(IListManager).get(part0)
             if language is None and mlist is None:
-                raise urllib2.URLError('Bad language or list name')
+                raise URLError('Bad language or list name')
             elif mlist is None:
                 code = language.code
         elif len(parts) == 3:
             fqdn_listname, code, template = parts
             mlist = getUtility(IListManager).get(fqdn_listname)
             if mlist is None:
-                raise urllib2.URLError('Missing list')
+                raise URLError('Missing list')
             language = getUtility(ILanguageManager).get(code)
             if language is None:
-                raise urllib2.URLError('No such language')
+                raise URLError('No such language')
             code = language.code
         else:
-            raise urllib2.URLError('No such file')
+            raise URLError('No such file')
         # Find the template, mutating any missing template exception.
         try:
             path, fp = find(template, mlist, code)
         except TemplateNotFoundError:
-            raise urllib2.URLError('No such file')
+            raise URLError('No such file')
         return addinfourl(fp, {}, original_url)
 
 
@@ -97,10 +97,10 @@
     """Loader of templates, with caching and support for mailman:// URIs."""
 
     def __init__(self):
-        opener = urllib2.build_opener(MailmanHandler())
-        urllib2.install_opener(opener)
+        opener = build_opener(MailmanHandler())
+        install_opener(opener)
 
     def get(self, uri):
         """See `ITemplateLoader`."""
-        with closing(urllib2.urlopen(uri)) as fp:
-            return fp.read().decode('utf-8')
+        with closing(urlopen(uri)) as fp:
+            return fp.read()

=== modified file 'src/mailman/app/tests/test_bounces.py'
--- src/mailman/app/tests/test_bounces.py	2014-01-07 03:43:59 +0000
+++ src/mailman/app/tests/test_bounces.py	2014-12-22 17:38:14 +0000
@@ -334,7 +334,7 @@
         send_probe(self._member, self._msg)
         message = get_queue_messages('virgin')[0].msg
         self.assertEqual(
-            message['Subject'],
+            message['subject'].encode(),
             '=?utf-8?q?ailing-may_ist-lay_Test_obe-pray_essage-may?=')
 
     def test_probe_notice_with_member_nonenglish(self):
@@ -533,7 +533,7 @@
 
     def test_no_sender(self):
         # The message won't be bounced if it has no discernible sender.
-        self._msg.sender = None
+        del self._msg['from']
         bounce_message(self._mlist, self._msg)
         items = get_queue_messages('virgin')
         # Nothing in the virgin queue means nothing's been bounced.

=== modified file 'src/mailman/app/tests/test_inject.py'
--- src/mailman/app/tests/test_inject.py	2014-01-01 14:59:42 +0000
+++ src/mailman/app/tests/test_inject.py	2014-12-22 17:38:14 +0000
@@ -64,7 +64,7 @@
         self.assertEqual(len(items), 1)
         self.assertMultiLineEqual(items[0].msg.as_string(),
                                   self.msg.as_string())
-        self.assertEqual(items[0].msgdata['listname'], 'test@example.com')
+        self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
         self.assertEqual(items[0].msgdata['original_size'],
                          len(self.msg.as_string()))
 
@@ -84,7 +84,7 @@
         self.assertEqual(len(items), 1)
         self.assertMultiLineEqual(items[0].msg.as_string(),
                                   self.msg.as_string())
-        self.assertEqual(items[0].msgdata['listname'], 'test@example.com')
+        self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
         self.assertEqual(items[0].msgdata['original_size'],
                          len(self.msg.as_string()))
 
@@ -144,7 +144,7 @@
 
     def setUp(self):
         self.mlist = create_list('test@example.com')
-        self.text = b"""\
+        self.text = """\
 From: bart@example.com
 To: test@example.com
 Subject: A test message
@@ -171,7 +171,7 @@
         # Delete that header because it is not in the original text.
         del items[0].msg['x-message-id-hash']
         self.assertMultiLineEqual(items[0].msg.as_string(), self.text)
-        self.assertEqual(items[0].msgdata['listname'], 'test@example.com')
+        self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
         self.assertEqual(items[0].msgdata['original_size'],
                          # Add back the X-Message-ID-Header which was in the
                          # message contributing to the original_size, but
@@ -196,7 +196,7 @@
         # Remove the X-Message-ID-Hash header which isn't in the original text.
         del items[0].msg['x-message-id-hash']
         self.assertMultiLineEqual(items[0].msg.as_string(), self.text)
-        self.assertEqual(items[0].msgdata['listname'], 'test@example.com')
+        self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
         self.assertEqual(items[0].msgdata['original_size'],
                          # Add back the X-Message-ID-Header which was in the
                          # message contributing to the original_size, but

=== modified file 'src/mailman/app/tests/test_subscriptions.py'
--- src/mailman/app/tests/test_subscriptions.py	2014-01-01 14:59:42 +0000
+++ src/mailman/app/tests/test_subscriptions.py	2014-12-22 17:38:14 +0000
@@ -32,6 +32,7 @@
 
 from mailman.app.lifecycle import create_list
 from mailman.interfaces.address import InvalidEmailAddressError
+from mailman.interfaces.member import MemberRole, MissingPreferredAddressError
 from mailman.interfaces.subscriptions import (
     MissingUserError, ISubscriptionService)
 from mailman.testing.layers import ConfigLayer
@@ -57,3 +58,14 @@
         with self.assertRaises(InvalidEmailAddressError) as cm:
             self._service.join('test.example.com', 'bogus')
         self.assertEqual(cm.exception.email, 'bogus')
+
+    def test_missing_preferred_address(self):
+        # A user cannot join a mailing list if they have no preferred address.
+        anne = self._service.join(
+            'test.example.com', 'anne@example.com', 'Anne Person')
+        # Try to join Anne as a user with a different role.  Her user has no
+        # preferred address, so this will fail.
+        self.assertRaises(MissingPreferredAddressError,
+                          self._service.join,
+                          'test.example.com', anne.user.user_id,
+                          role=MemberRole.owner)

=== modified file 'src/mailman/app/tests/test_templates.py'
--- src/mailman/app/tests/test_templates.py	2014-04-14 16:14:13 +0000
+++ src/mailman/app/tests/test_templates.py	2014-12-22 17:38:14 +0000
@@ -26,18 +26,18 @@
 
 
 import os
+import six
 import shutil
-import urllib2
 import tempfile
 import unittest
 
-from zope.component import getUtility
-
 from mailman.app.lifecycle import create_list
 from mailman.config import config
 from mailman.interfaces.languages import ILanguageManager
 from mailman.interfaces.templates import ITemplateLoader
 from mailman.testing.layers import ConfigLayer
+from six.moves.urllib_error import URLError
+from zope.component import getUtility
 
 
 
@@ -98,32 +98,32 @@
         self.assertEqual(content, 'Test content')
 
     def test_uri_not_found(self):
-        with self.assertRaises(urllib2.URLError) as cm:
+        with self.assertRaises(URLError) as cm:
             self._loader.get('mailman:///missing.txt')
         self.assertEqual(cm.exception.reason, 'No such file')
 
     def test_shorter_url_error(self):
-        with self.assertRaises(urllib2.URLError) as cm:
+        with self.assertRaises(URLError) as cm:
             self._loader.get('mailman:///')
         self.assertEqual(cm.exception.reason, 'No template specified')
 
     def test_short_url_error(self):
-        with self.assertRaises(urllib2.URLError) as cm:
+        with self.assertRaises(URLError) as cm:
             self._loader.get('mailman://')
         self.assertEqual(cm.exception.reason, 'No template specified')
 
     def test_bad_language(self):
-        with self.assertRaises(urllib2.URLError) as cm:
+        with self.assertRaises(URLError) as cm:
             self._loader.get('mailman:///xx/demo.txt')
         self.assertEqual(cm.exception.reason, 'Bad language or list name')
 
     def test_bad_mailing_list(self):
-        with self.assertRaises(urllib2.URLError) as cm:
+        with self.assertRaises(URLError) as cm:
             self._loader.get('mailman:///missing@example.com/demo.txt')
         self.assertEqual(cm.exception.reason, 'Bad language or list name')
 
     def test_too_many_path_components(self):
-        with self.assertRaises(urllib2.URLError) as cm:
+        with self.assertRaises(URLError) as cm:
             self._loader.get('mailman:///missing@example.com/en/foo/demo.txt')
         self.assertEqual(cm.exception.reason, 'No such file')
 
@@ -132,8 +132,8 @@
         test_text = b'\xe4\xb8\xad'
         path = os.path.join(self.var_dir, 'templates', 'site', 'it')
         os.makedirs(path)
-        with open(os.path.join(path, 'demo.txt'), 'w') as fp:
-            print(test_text, end='', file=fp)
+        with open(os.path.join(path, 'demo.txt'), 'wb') as fp:
+            fp.write(test_text)
         content = self._loader.get('mailman:///it/demo.txt')
-        self.assertTrue(isinstance(content, unicode))
+        self.assertIsInstance(content, six.text_type)
         self.assertEqual(content, test_text.decode('utf-8'))

=== modified file 'src/mailman/archiving/mailarchive.py'
--- src/mailman/archiving/mailarchive.py	2014-12-09 01:38:26 +0000
+++ src/mailman/archiving/mailarchive.py	2014-12-22 17:38:14 +0000
@@ -25,13 +25,11 @@
     ]
 
 
-from urllib import quote
-from urlparse import urljoin
-from zope.interface import implementer
-
 from mailman.config import config
 from mailman.config.config import external_configuration
 from mailman.interfaces.archiver import ArchivePolicy, IArchiver
+from six.moves.urllib_parse import quote, urljoin
+from zope.interface import implementer
 
 
 
@@ -77,5 +75,5 @@
         if mlist.archive_policy is ArchivePolicy.public:
             config.switchboards['out'].enqueue(
                 msg,
-                listname=mlist.fqdn_listname,
+                listid=mlist.list_id,
                 recipients=[self.recipient])

=== modified file 'src/mailman/archiving/mhonarc.py'
--- src/mailman/archiving/mhonarc.py	2014-12-09 01:38:26 +0000
+++ src/mailman/archiving/mhonarc.py	2014-12-22 17:38:14 +0000
@@ -28,13 +28,12 @@
 import logging
 import subprocess
 
-from urlparse import urljoin
-from zope.interface import implementer
-
 from mailman.config import config
 from mailman.config.config import external_configuration
 from mailman.interfaces.archiver import IArchiver
 from mailman.utilities.string import expand
+from six.moves.urllib_parse import urljoin
+from zope.interface import implementer
 
 
 log = logging.getLogger('mailman.archiver')
@@ -84,7 +83,7 @@
         command = expand(self.command, substitutions)
         proc = subprocess.Popen(
             command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
-            shell=True)
+            universal_newlines=True, shell=True)
         stdout, stderr = proc.communicate(msg.as_string())
         if proc.returncode != 0:
             log.error('%s: mhonarc subprocess had non-zero exit code: %s' %

=== modified file 'src/mailman/archiving/prototype.py'
--- src/mailman/archiving/prototype.py	2014-12-09 01:38:26 +0000
+++ src/mailman/archiving/prototype.py	2014-12-22 17:38:14 +0000
@@ -30,14 +30,13 @@
 import logging
 
 from datetime import timedelta
+from flufl.lock import Lock, TimeOutError
 from mailbox import Maildir
-from urlparse import urljoin
-
-from flufl.lock import Lock, TimeOutError
-from zope.interface import implementer
-
 from mailman.config import config
 from mailman.interfaces.archiver import IArchiver
+from six.moves.urllib_parse import urljoin
+from zope.interface import implementer
+
 
 log = logging.getLogger('mailman.error')
 

=== modified file 'src/mailman/archiving/tests/test_prototype.py'
--- src/mailman/archiving/tests/test_prototype.py	2014-01-01 14:59:42 +0000
+++ src/mailman/archiving/tests/test_prototype.py	2014-12-22 17:38:14 +0000
@@ -89,13 +89,13 @@
     def _find(self, path):
         all_filenames = set()
         for dirpath, dirnames, filenames in os.walk(path):
-            if not isinstance(dirpath, unicode):
-                dirpath = unicode(dirpath)
+            if isinstance(dirpath, bytes):
+                dirpath = dirpath.decode('utf-8')
             all_filenames.add(dirpath)
             for filename in filenames:
                 new_filename = filename
-                if not isinstance(filename, unicode):
-                    new_filename = unicode(filename)
+                if isinstance(filename, bytes):
+                    new_filename = filename.decode('utf-8')
                 all_filenames.add(os.path.join(dirpath, new_filename))
         return all_filenames
 

=== modified file 'src/mailman/bin/export.py'
--- src/mailman/bin/export.py	2014-01-01 14:59:42 +0000
+++ src/mailman/bin/export.py	2014-12-22 17:38:14 +0000
@@ -134,7 +134,7 @@
             print >> self._fp, '<%s%s/>' % (_name, attrs)
         else:
             # The value might contain angle brackets.
-            value = escape(unicode(_value))
+            value = escape(_value.decode('utf-8'))
             print >> self._fp, '<%s%s>%s</%s>' % (_name, attrs, value, _name)
 
     def _do_list_categories(self, mlist, k, subcat=None):

=== modified file 'src/mailman/bin/gate_news.py'
--- src/mailman/bin/gate_news.py	2014-11-20 01:29:44 +0000
+++ src/mailman/bin/gate_news.py	2014-12-22 17:38:14 +0000
@@ -149,7 +149,7 @@
                 # Post the message to the locked list
                 inq = Switchboard(config.INQUEUE_DIR)
                 inq.enqueue(msg,
-                            listname=mlist.internal_name(),
+                            listid=mlist.list_id,
                             fromusenet=True)
                 log.info('posted to list %s: %7d', listname, num)
         except nntplib.NNTPError as e:

=== modified file 'src/mailman/bin/mailman.py'
--- src/mailman/bin/mailman.py	2014-01-01 14:59:42 +0000
+++ src/mailman/bin/mailman.py	2014-12-22 17:38:14 +0000
@@ -28,6 +28,7 @@
 import os
 import argparse
 
+from functools import cmp_to_key
 from zope.interface.verify import verifyObject
 
 from mailman.core.i18n import _
@@ -77,9 +78,14 @@
             return -1
         elif other.name == 'help':
             return 1
+        elif command.name < other.name:
+            return -1
+        elif command.name == other.name:
+            return 0
         else:
-            return cmp(command.name, other.name)
-    subcommands.sort(cmp=sort_function)
+            assert command.name > other.name
+            return 1
+    subcommands.sort(key=cmp_to_key(sort_function))
     for command in subcommands:
         command_parser = subparser.add_parser(
             command.name, help=_(command.__doc__))

=== modified file 'src/mailman/chains/headers.py'
--- src/mailman/chains/headers.py	2014-01-01 14:59:42 +0000
+++ src/mailman/chains/headers.py	2014-12-22 17:38:14 +0000
@@ -122,7 +122,7 @@
         """See `IMutableChain`."""
         # Remove all dynamically created rules.  Use the keys so we can mutate
         # the dictionary inside the loop.
-        for rule_name in config.rules.keys():
+        for rule_name in list(config.rules):
             if rule_name.startswith('header-match-'):
                 del config.rules[rule_name]
         self._extended_links = []

=== modified file 'src/mailman/chains/hold.py'
--- src/mailman/chains/hold.py	2014-01-01 14:59:42 +0000
+++ src/mailman/chains/hold.py	2014-12-22 17:38:14 +0000
@@ -157,7 +157,7 @@
         if original_subject is None:
             original_subject = _('(no subject)')
         else:
-            original_subject = oneline(original_subject, charset)
+            original_subject = oneline(original_subject, in_unicode=True)
         substitutions = dict(
             listname    = mlist.fqdn_listname,
             subject     = original_subject,

=== modified file 'src/mailman/commands/cli_import.py'
--- src/mailman/commands/cli_import.py	2014-04-14 16:14:13 +0000
+++ src/mailman/commands/cli_import.py	2014-12-22 17:38:14 +0000
@@ -26,16 +26,15 @@
 
 
 import sys
-import cPickle
-
-from zope.component import getUtility
-from zope.interface import implementer
 
 from mailman.core.i18n import _
 from mailman.database.transaction import transactional
 from mailman.interfaces.command import ICLISubCommand
 from mailman.interfaces.listmanager import IListManager
 from mailman.utilities.importer import import_config_pck, Import21Error
+from six.moves import cPickle
+from zope.component import getUtility
+from zope.interface import implementer
 
 
 
@@ -78,7 +77,7 @@
         assert len(args.pickle_file) == 1, (
             'Unexpected positional arguments: %s' % args.pickle_file)
         filename = args.pickle_file[0]
-        with open(filename) as fp:
+        with open(filename, 'rb') as fp:
             while True:
                 try:
                     config_dict = cPickle.load(fp)

=== modified file 'src/mailman/commands/cli_inject.py'
--- src/mailman/commands/cli_inject.py	2014-01-01 14:59:42 +0000
+++ src/mailman/commands/cli_inject.py	2014-12-22 17:38:14 +0000
@@ -27,14 +27,13 @@
 
 import sys
 
-from zope.component import getUtility
-from zope.interface import implementer
-
 from mailman.app.inject import inject_text
 from mailman.config import config
 from mailman.core.i18n import _
 from mailman.interfaces.command import ICLISubCommand
 from mailman.interfaces.listmanager import IListManager
+from zope.component import getUtility
+from zope.interface import implementer
 
 
 
@@ -49,7 +48,7 @@
         self.parser = parser
         command_parser.add_argument(
             '-q', '--queue',
-            type=unicode, help=_("""
+            help=_("""
             The name of the queue to inject the message to.  QUEUE must be one
             of the directories inside the qfiles directory.  If omitted, the
             incoming queue is used."""))
@@ -59,7 +58,7 @@
             help=_('Show a list of all available queue names and exit.'))
         command_parser.add_argument(
             '-f', '--filename',
-            type=unicode, help=_("""
+            help=_("""
             Name of file containing the message to inject.  If not given, or
             '-' (without the quotes) standard input is used."""))
         # Required positional argument.

=== modified file 'src/mailman/commands/cli_lists.py'
--- src/mailman/commands/cli_lists.py	2014-11-20 01:29:44 +0000
+++ src/mailman/commands/cli_lists.py	2014-12-22 17:38:14 +0000
@@ -135,12 +135,12 @@
         self.parser = parser
         command_parser.add_argument(
             '--language',
-            type=unicode, metavar='CODE', help=_("""\
+            metavar='CODE', help=_("""\
             Set the list's preferred language to CODE, which must be a
             registered two letter language code."""))
         command_parser.add_argument(
             '-o', '--owner',
-            type=unicode, action='append', default=[],
+            action='append', default=[],
             dest='owners', metavar='OWNER', help=_("""\
             Specify a listowner email address.  If the address is not
             currently registered with Mailman, the address is registered and

=== modified file 'src/mailman/commands/cli_members.py'
--- src/mailman/commands/cli_members.py	2014-01-01 14:59:42 +0000
+++ src/mailman/commands/cli_members.py	2014-12-22 17:38:14 +0000
@@ -197,8 +197,6 @@
                     continue
                 # Parse the line and ensure that the values are unicodes.
                 display_name, email = parseaddr(line)
-                display_name = display_name.decode(fp.encoding)
-                email = email.decode(fp.encoding)
                 # Give the user a default, user-friendly password.
                 password = generate(int(config.passwords.password_length))
                 try:

=== modified file 'src/mailman/commands/cli_qfile.py'
--- src/mailman/commands/cli_qfile.py	2014-01-01 14:59:42 +0000
+++ src/mailman/commands/cli_qfile.py	2014-12-22 17:38:14 +0000
@@ -25,14 +25,14 @@
     ]
 
 
-import cPickle
-
-from pprint import PrettyPrinter
-from zope.interface import implementer
+import six
 
 from mailman.core.i18n import _
 from mailman.interfaces.command import ICLISubCommand
 from mailman.utilities.interact import interact
+from pprint import PrettyPrinter
+from six.moves import cPickle
+from zope.interface import implementer
 
 
 m = []
@@ -71,7 +71,7 @@
         """See `ICLISubCommand`."""
         printer = PrettyPrinter(indent=4)
         assert len(args.qfile) == 1, 'Wrong number of positional arguments'
-        with open(args.qfile[0]) as fp:
+        with open(args.qfile[0], 'rb') as fp:
             while True:
                 try:
                     m.append(cPickle.load(fp))
@@ -82,7 +82,7 @@
             for i, obj in enumerate(m):
                 count = i + 1
                 print(_('<----- start object $count ----->'))
-                if isinstance(obj, basestring):
+                if isinstance(obj, six.string_types):
                     print(obj)
                 else:
                     printer.pprint(obj)

=== modified file 'src/mailman/commands/docs/echo.rst'
--- src/mailman/commands/docs/echo.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/commands/docs/echo.rst	2014-12-22 17:38:14 +0000
@@ -24,7 +24,7 @@
     >>> from mailman.email.message import Message
     >>> print(command.process(mlist, Message(), {}, ('foo', 'bar'), results))
     ContinueProcessing.yes
-    >>> print(unicode(results))
+    >>> print(str(results))
     The results of your email command are provided below.
     <BLANKLINE>
     echo foo bar

=== modified file 'src/mailman/commands/docs/help.rst'
--- src/mailman/commands/docs/help.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/commands/docs/help.rst	2014-12-22 17:38:14 +0000
@@ -25,7 +25,7 @@
     >>> from mailman.email.message import Message
     >>> print(help.process(mlist, Message(), {}, (), results))
     ContinueProcessing.yes
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     confirm     - Confirm a subscription request.
@@ -44,19 +44,19 @@
     >>> results = Results()
     >>> print(help.process(mlist, Message(), {}, ('help',), results))
     ContinueProcessing.yes
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     help [command]
     Get help about available email commands.
     <BLANKLINE>
-    
+
 Some commands have even more detailed help.
 
     >>> results = Results()
     >>> print(help.process(mlist, Message(), {}, ('join',), results))
     ContinueProcessing.yes
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     join [digest=<no|mime|plain>]

=== modified file 'src/mailman/commands/docs/inject.rst'
--- src/mailman/commands/docs/inject.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/commands/docs/inject.rst	2014-12-22 17:38:14 +0000
@@ -94,7 +94,7 @@
 
     >>> dump_msgdata(items[0].msgdata)
     _parsemsg    : False
-    listname     : test@example.com
+    listid       : test.example.com
     original_size: 203
     version      : 3
 
@@ -122,7 +122,7 @@
 
     >>> dump_msgdata(items[0].msgdata)
     _parsemsg    : False
-    listname     : test@example.com
+    listid       : test.example.com
     original_size: 203
     version      : 3
 
@@ -133,7 +133,7 @@
 The message text can also be provided on standard input.
 ::
 
-    >>> from StringIO import StringIO
+    >>> from six import StringIO
 
     # Remember: we've got unicode literals turned on.
     >>> standard_in = StringIO(str("""\
@@ -167,7 +167,7 @@
 
     >>> dump_msgdata(items[0].msgdata)
     _parsemsg    : False
-    listname     : test@example.com
+    listid       : test.example.com
     original_size: 211
     version      : 3
 
@@ -195,7 +195,7 @@
     _parsemsg    : False
     bar          : two
     foo          : one
-    listname     : test@example.com
+    listid       : test.example.com
     original_size: 203
     version      : 3
 

=== modified file 'src/mailman/commands/docs/members.rst'
--- src/mailman/commands/docs/members.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/commands/docs/members.rst	2014-12-22 17:38:14 +0000
@@ -229,15 +229,14 @@
 taken from standard input.
 ::
 
-    >>> from StringIO import StringIO
+    >>> from six import StringIO
     >>> fp = StringIO()
-    >>> fp.encoding = 'us-ascii'
     >>> for address in ('dperson@example.com',
     ...                 'Elly Person <eperson@example.com>',
     ...                 'fperson@example.com (Fred Person)',
     ...                 ):
     ...         print(address, file=fp)
-    >>> fp.seek(0)
+    >>> filepos = fp.seek(0)
     >>> import sys
     >>> sys.stdin = fp
 

=== modified file 'src/mailman/commands/docs/membership.rst'
--- src/mailman/commands/docs/membership.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/commands/docs/membership.rst	2014-12-22 17:38:14 +0000
@@ -45,7 +45,7 @@
     >>> from mailman.email.message import Message
     >>> print(join.process(mlist, Message(), {}, (), results))
     ContinueProcessing.no
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     join: No valid address found to subscribe
@@ -60,7 +60,7 @@
     >>> results = Results()
     >>> print(subscribe.process(mlist, Message(), {}, (), results))
     ContinueProcessing.no
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     subscribe: No valid address found to subscribe
@@ -79,7 +79,7 @@
     >>> results = Results()
     >>> print(join.process(mlist, msg, {}, (), results))
     ContinueProcessing.yes
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     Confirmation email sent to Anne Person <anne@example.com>
@@ -150,7 +150,7 @@
     >>> results = Results()
     >>> print(confirm.process(mlist, msg, {}, (token,), results))
     ContinueProcessing.yes
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     Confirmed
@@ -208,7 +208,7 @@
     >>> results = Results()
     >>> print(confirm.process(mlist_2, msg, {}, (token,), results))
     ContinueProcessing.yes
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     Confirmed
@@ -241,7 +241,7 @@
     >>> results = Results()
     >>> print(leave.process(mlist_2, msg, {}, (), results))
     ContinueProcessing.yes
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     Anne Person <anne@example.com> left baker@example.com
@@ -278,7 +278,7 @@
     >>> print(leave.process(mlist, msg, {}, (), results))
     ContinueProcessing.no
 
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     Invalid or unverified email address: anne.person@example.org
@@ -299,7 +299,7 @@
     >>> print(leave.process(mlist, msg, {}, (), results))
     ContinueProcessing.yes
 
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     Anne Person <anne.person@example.org> left alpha@example.com
@@ -354,7 +354,7 @@
     >>> print(confirm.process(mlist, msg, {}, (token,), results))
     ContinueProcessing.yes
 
-    >>> print(unicode(results))
+    >>> print(results)
     The results of your email command are provided below.
     <BLANKLINE>
     Confirmed

=== modified file 'src/mailman/commands/docs/qfile.rst'
--- src/mailman/commands/docs/qfile.rst	2012-03-26 12:04:00 +0000
+++ src/mailman/commands/docs/qfile.rst	2014-12-22 17:38:14 +0000
@@ -47,7 +47,6 @@
     >>> command.process(FakeArgs)
     [----- start pickle -----]
     <----- start object 1 ----->
-    From nobody ...
     From: aperson@example.com
     To: test@example.com
     Subject: Uh oh
@@ -55,11 +54,7 @@
     I borkeded Mailman.
     <BLANKLINE>
     <----- start object 2 ----->
-    {   u'_parsemsg': False,
-        'bad': u'yes',
-        'bar': u'baz',
-        'foo': 7,
-        u'version': 3}
+    {'_parsemsg': False, 'bad': 'yes', 'bar': 'baz', 'foo': 7, 'version': 3}
     [----- end pickle -----]
 
 Maybe we don't want to print the contents of the file though, in case we want

=== modified file 'src/mailman/commands/docs/withlist.rst'
--- src/mailman/commands/docs/withlist.rst	2014-10-31 03:12:00 +0000
+++ src/mailman/commands/docs/withlist.rst	2014-12-22 17:38:14 +0000
@@ -52,10 +52,10 @@
     >>> with open(os.path.join(config.VAR_DIR, 'showme.py'), 'w') as fp:
     ...     print("""\
     ... def showme(mailing_list):
-    ...     print "The list's name is", mailing_list.fqdn_listname
+    ...     print("The list's name is", mailing_list.fqdn_listname)
     ...
     ... def displayname(mailing_list):
-    ...     print "The list's display name is", mailing_list.display_name
+    ...     print("The list's display name is", mailing_list.display_name)
     ... """, file=fp)
 
 If the name of the function is the same as the module, then you only need to

=== modified file 'src/mailman/commands/tests/test_conf.py'
--- src/mailman/commands/tests/test_conf.py	2014-01-01 14:59:42 +0000
+++ src/mailman/commands/tests/test_conf.py	2014-12-22 17:38:14 +0000
@@ -31,9 +31,9 @@
 import tempfile
 import unittest
 
-from StringIO import StringIO
 from mailman.commands.cli_conf import Conf
 from mailman.testing.layers import ConfigLayer
+from six import StringIO
 
 
 

=== modified file 'src/mailman/commands/tests/test_help.py'
--- src/mailman/commands/tests/test_help.py	2014-01-01 14:59:42 +0000
+++ src/mailman/commands/tests/test_help.py	2014-12-22 17:38:14 +0000
@@ -47,11 +47,11 @@
     def test_too_many_arguments(self):
         # Error message when too many help arguments are given.
         results = Results()
-        status = self._help.process(self._mlist, Message(), {}, 
+        status = self._help.process(self._mlist, Message(), {},
                                     ('more', 'than', 'one'),
                                     results)
         self.assertEqual(status, ContinueProcessing.no)
-        self.assertEqual(unicode(results), """\
+        self.assertEqual(str(results), """\
 The results of your email command are provided below.
 
 help: too many arguments: more than one
@@ -60,10 +60,10 @@
     def test_no_such_command(self):
         # Error message when asking for help on an existent command.
         results = Results()
-        status = self._help.process(self._mlist, Message(), {}, 
+        status = self._help.process(self._mlist, Message(), {},
                                     ('doesnotexist',), results)
         self.assertEqual(status, ContinueProcessing.no)
-        self.assertEqual(unicode(results), """\
+        self.assertEqual(str(results), """\
 The results of your email command are provided below.
 
 help: no such command: doesnotexist

=== modified file 'src/mailman/config/config.py'
--- src/mailman/config/config.py	2014-11-01 03:35:02 +0000
+++ src/mailman/config/config.py	2014-12-22 17:38:14 +0000
@@ -30,11 +30,12 @@
 import os
 import sys
 
-from ConfigParser import SafeConfigParser
 from flufl.lock import Lock
 from lazr.config import ConfigSchema, as_boolean
-from pkg_resources import resource_stream, resource_string
+from pkg_resources import resource_filename, resource_string as resource_bytes
+from six.moves.configparser import ConfigParser, RawConfigParser
 from string import Template
+from unittest.mock import patch
 from zope.component import getUtility
 from zope.event import notify
 from zope.interface import implementer
@@ -66,6 +67,11 @@
 # enabled: yes
 # recipient: your.address@your.domain"""
 
+class _NonStrictRawConfigParser(RawConfigParser):
+    def __init__(self, *args, **kws):
+        kws['strict'] = False
+        super().__init__(*args, **kws)
+
 
 
 @implementer(IConfiguration)
@@ -99,30 +105,27 @@
 
     def load(self, filename=None):
         """Load the configuration from the schema and config files."""
-        schema_file = config_file = None
-        try:
-            schema_file = resource_stream('mailman.config', 'schema.cfg')
-            schema = ConfigSchema('schema.cfg', schema_file)
-            # If a configuration file was given, load it now too.  First, load
-            # the absolute minimum default configuration, then if a
-            # configuration filename was given by the user, push it.
-            config_file = resource_stream('mailman.config', 'mailman.cfg')
-            self._config = schema.loadFile(config_file, 'mailman.cfg')
-            if filename is not None:
-                self.filename = filename
-                with open(filename) as user_config:
-                    self._config.push(filename, user_config.read())
-        finally:
-            if schema_file:
-                schema_file.close()
-            if config_file:
-                config_file.close()
-        self._post_process()
+        schema_file = resource_filename('mailman.config', 'schema.cfg')
+        schema = ConfigSchema(schema_file)
+        # If a configuration file was given, load it now too.  First, load
+        # the absolute minimum default configuration, then if a
+        # configuration filename was given by the user, push it.
+        config_file = resource_filename('mailman.config', 'mailman.cfg')
+        self._config = schema.load(config_file)
+        if filename is not None:
+            self.filename = filename
+            with open(filename, 'r', encoding='utf-8') as user_config:
+                self.push(filename, user_config.read())
 
     def push(self, config_name, config_string):
         """Push a new configuration onto the stack."""
         self._clear()
-        self._config.push(config_name, config_string)
+        # In Python 3, the RawConfigParser() must be created with
+        # strict=False, otherwise we'll get a DuplicateSectionError.
+        # See https://bugs.launchpad.net/lazr.config/+bug/1397779
+        with patch('lazr.config._config.RawConfigParser',
+                   _NonStrictRawConfigParser):
+            self._config.push(config_name, config_string)
         self._post_process()
 
     def pop(self, config_name):
@@ -266,7 +269,7 @@
 
 
 
-def load_external(path, encoding=None):
+def load_external(path):
     """Load the configuration file named by path.
 
     :param path: A string naming the location of the external configuration
@@ -275,21 +278,16 @@
         value must name a ``.cfg`` file located within Python's import path,
         however the trailing ``.cfg`` suffix is implied (don't provide it
         here).
-    :param encoding: The encoding to apply to the data read from path.  If
-        None, then bytes will be returned.
-    :return: A unicode string or bytes, depending on ``encoding``.
+    :return: The contents of the configuration file.
+    :rtype: str
     """
     # Is the context coming from a file system or Python path?
     if path.startswith('python:'):
         resource_path = path[7:]
         package, dot, resource = resource_path.rpartition('.')
-        config_string = resource_string(package, resource + '.cfg')
-    else:
-        with open(path, 'rb') as fp:
-            config_string = fp.read()
-    if encoding is None:
-        return config_string
-    return config_string.decode(encoding)
+        return resource_bytes(package, resource + '.cfg').decode('utf-8')
+    with open(path, 'r', encoding='utf-8') as fp:
+        return fp.read()
 
 
 def external_configuration(path):
@@ -305,7 +303,7 @@
     """
     # Is the context coming from a file system or Python path?
     cfg_path = expand_path(path)
-    parser = SafeConfigParser()
+    parser = ConfigParser()
     files = parser.read(cfg_path)
     if files != [cfg_path]:
         raise MissingConfigurationFileError(path)

=== modified file 'src/mailman/config/tests/test_configuration.py'
--- src/mailman/config/tests/test_configuration.py	2014-11-09 12:52:58 +0000
+++ src/mailman/config/tests/test_configuration.py	2014-12-22 17:38:14 +0000
@@ -32,6 +32,7 @@
 import tempfile
 import unittest
 
+from contextlib import ExitStack
 from mailman.config.config import (
     Configuration, external_configuration, load_external)
 from mailman.interfaces.configuration import (
@@ -65,26 +66,13 @@
 class TestExternal(unittest.TestCase):
     """Test external configuration file loading APIs."""
 
-    def test_load_external_by_filename_as_bytes(self):
+    def test_load_external_by_filename(self):
         filename = resource_filename('mailman.config', 'postfix.cfg')
         contents = load_external(filename)
-        self.assertIsInstance(contents, bytes)
-        self.assertEqual(contents[:9], b'[postfix]')
+        self.assertEqual(contents[:9], '[postfix]')
 
-    def test_load_external_by_path_as_bytes(self):
+    def test_load_external_by_path(self):
         contents = load_external('python:mailman.config.postfix')
-        self.assertIsInstance(contents, bytes)
-        self.assertEqual(contents[:9], b'[postfix]')
-
-    def test_load_external_by_filename_as_string(self):
-        filename = resource_filename('mailman.config', 'postfix.cfg')
-        contents = load_external(filename, encoding='utf-8')
-        self.assertIsInstance(contents, unicode)
-        self.assertEqual(contents[:9], '[postfix]')
-
-    def test_load_external_by_path_as_string(self):
-        contents = load_external('python:mailman.config.postfix', 'utf-8')
-        self.assertIsInstance(contents, unicode)
         self.assertEqual(contents[:9], '[postfix]')
 
     def test_external_configuration_by_filename(self):
@@ -121,8 +109,12 @@
         # Use a fake sys.exit() function that records that it was called, and
         # that prevents further processing.
         config = Configuration()
-        # Suppress warning messages in the test output.
-        with self.assertRaises(SystemExit) as cm, mock.patch('sys.stderr'):
+        # Suppress warning messages in the test output.  Also, make sure that
+        # the config.load() call doesn't break global state.
+        with ExitStack() as resources:
+            resources.enter_context(mock.patch('sys.stderr'))
+            resources.enter_context(mock.patch.object(config, '_clear'))
+            cm = resources.enter_context(self.assertRaises(SystemExit))
             config.load(filename)
         self.assertEqual(cm.exception.args, (1,))
 
@@ -138,7 +130,11 @@
 log_dir: $nopath/log_dir
 """, file=fp)
         config = Configuration()
-        # Suppress warning messages in the test output.
-        with self.assertRaises(SystemExit) as cm, mock.patch('sys.stderr'):
+        # Suppress warning messages in the test output.  Also, make sure that
+        # the config.load() call doesn't break global state.
+        with ExitStack() as resources:
+            resources.enter_context(mock.patch('sys.stderr'))
+            resources.enter_context(mock.patch.object(config, '_clear'))
+            cm = resources.enter_context(self.assertRaises(SystemExit))
             config.load(filename)
         self.assertEqual(cm.exception.args, (1,))

=== modified file 'src/mailman/core/docs/runner.rst'
--- src/mailman/core/docs/runner.rst	2014-11-01 03:35:02 +0000
+++ src/mailman/core/docs/runner.rst	2014-12-22 17:38:14 +0000
@@ -55,7 +55,7 @@
     ... A test message.
     ... """)
     >>> switchboard = config.switchboards['test']
-    >>> filebase = switchboard.enqueue(msg, listname=mlist.fqdn_listname,
+    >>> filebase = switchboard.enqueue(msg, listid=mlist.list_id,
     ...                                foo='yes', bar='no')
     >>> runner.run()
     >>> print(runner.msg.as_string())
@@ -69,7 +69,7 @@
     bar      : no
     foo      : yes
     lang     : en
-    listname : test@example.com
+    listid   : test.example.com
     version  : 3
 
 XXX More of the Runner API should be tested.

=== modified file 'src/mailman/core/initialize.py'
--- src/mailman/core/initialize.py	2014-11-09 12:52:58 +0000
+++ src/mailman/core/initialize.py	2014-12-22 17:38:14 +0000
@@ -39,7 +39,7 @@
 import os
 import sys
 
-from pkg_resources import resource_string
+from pkg_resources import resource_string as resource_bytes
 from zope.component import getUtility
 from zope.configuration import xmlconfig
 
@@ -109,8 +109,8 @@
     :param config_path: The path to the configuration file.
     :type config_path: string
     """
-    zcml = resource_string('mailman.config', 'configure.zcml')
-    xmlconfig.string(zcml)
+    zcml = resource_bytes('mailman.config', 'configure.zcml')
+    xmlconfig.string(zcml.decode('utf-8'))
     # By default, set the umask so that only owner and group can read and
     # write our files.  Specifically we must have g+rw and we probably want
     # o-rwx although I think in most cases it doesn't hurt if other can read

=== modified file 'src/mailman/core/logging.py'
--- src/mailman/core/logging.py	2014-11-01 16:49:15 +0000
+++ src/mailman/core/logging.py	2014-12-22 17:38:14 +0000
@@ -145,6 +145,9 @@
             continue
         if sub_name == 'locks':
             log = logging.getLogger('flufl.lock')
+            # Explicitly prevent flufl.lock from propagating its log messages
+            # to its root logger, i.e. the console.
+            log.propagate = False
         if sub_name == 'database':
             # Set both the SQLAlchemy and Alembic logs to the mailman.database
             # log configuration, essentially ignoring the alembic.cfg settings.

=== modified file 'src/mailman/core/pipelines.py'
--- src/mailman/core/pipelines.py	2014-01-01 14:59:42 +0000
+++ src/mailman/core/pipelines.py	2014-12-22 17:38:14 +0000
@@ -120,6 +120,7 @@
         'cleanse',
         'cleanse-dkim',
         'cook-headers',
+        'subject-prefix',
         'rfc-2369',
         'to-archive',
         'to-digest',

=== modified file 'src/mailman/core/runner.py'
--- src/mailman/core/runner.py	2014-12-09 11:25:45 +0000
+++ src/mailman/core/runner.py	2014-12-22 17:38:14 +0000
@@ -30,12 +30,7 @@
 import logging
 import traceback
 
-from cStringIO import StringIO
 from lazr.config import as_boolean, as_timedelta
-from zope.component import getUtility
-from zope.event import notify
-from zope.interface import implementer
-
 from mailman.config import config
 from mailman.core.i18n import _
 from mailman.core.logging import reopen
@@ -44,6 +39,10 @@
 from mailman.interfaces.listmanager import IListManager
 from mailman.interfaces.runner import IRunner, RunnerCrashEvent
 from mailman.utilities.string import expand
+from six.moves import cStringIO as StringIO
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import implementer
 
 
 dlog = logging.getLogger('mailman.debug')
@@ -218,11 +217,20 @@
         # them out of our sight.
         #
         # Find out which mailing list this message is destined for.
+        mlist = None
         missing = object()
-        listname = msgdata.get('listname', missing)
-        mlist = (None
-                 if listname is missing
-                 else getUtility(IListManager).get(unicode(listname)))
+        # First try to dig out the target list by id.  If there's no list-id
+        # in the metadata, fall back to the fqdn list name for backward
+        # compatibility.
+        list_manager = getUtility(IListManager)
+        list_id = msgdata.get('listid', missing)
+        if list_id is missing:
+            listname = msgdata.get('listname', missing)
+            # XXX Deprecate.
+            if listname is not missing:
+                mlist = list_manager.get(listname)
+        else:
+            mlist = list_manager.get_by_list_id(list_id)
         if mlist is None:
             elog.error(
                 '%s runner "%s" shunting message for missing list: %s',

=== modified file 'src/mailman/core/switchboard.py'
--- src/mailman/core/switchboard.py	2014-11-20 01:29:44 +0000
+++ src/mailman/core/switchboard.py	2014-12-22 17:38:14 +0000
@@ -37,22 +37,22 @@
 import time
 import email
 import pickle
-import cPickle
 import hashlib
 import logging
 
-from zope.interface import implementer
-
 from mailman.config import config
 from mailman.email.message import Message
 from mailman.interfaces.configuration import ConfigurationUpdatedEvent
 from mailman.interfaces.switchboard import ISwitchboard
 from mailman.utilities.filesystem import makedirs
 from mailman.utilities.string import expand
-
-
-# 20 bytes of all bits set, maximum hashlib.sha.digest() value.
-shamax = 0xffffffffffffffffffffffffffffffffffffffffL
+from six.moves import cPickle
+from zope.interface import implementer
+
+
+# 20 bytes of all bits set, maximum hashlib.sha.digest() value.  We do it this
+# way for Python 2/3 compatibility.
+shamax = int('0xffffffffffffffffffffffffffffffffffffffff', 16)
 # Small increment to add to time in case two entries have the same time.  This
 # prevents skipping one of two entries with the same time until the next pass.
 DELTA = .0001
@@ -92,7 +92,7 @@
         self.queue_directory = queue_directory
         # If configured to, create the directory if it doesn't yet exist.
         if config.create_paths:
-            makedirs(self.queue_directory, 0770)
+            makedirs(self.queue_directory, 0o770)
         # Fast track for no slices
         self._lower = None
         self._upper = None
@@ -112,37 +112,37 @@
         # of parallel runner processes.
         data = _metadata.copy()
         data.update(_kws)
-        listname = data.get('listname', '--nolist--')
+        list_id = data.get('listid', '--nolist--')
         # Get some data for the input to the sha hash.
-        now = time.time()
+        now = repr(time.time())
         if data.get('_plaintext'):
             protocol = 0
             msgsave = cPickle.dumps(str(_msg), protocol)
         else:
             protocol = pickle.HIGHEST_PROTOCOL
             msgsave = cPickle.dumps(_msg, protocol)
-        # listname is unicode but the input to the hash function must be an
-        # 8-bit string (eventually, a bytes object).
-        hashfood = msgsave + listname.encode('utf-8') + repr(now)
+        # The list-id field is a string but the input to the hash function must
+        # be bytes.
+        hashfood = msgsave + list_id.encode('utf-8') + now.encode('utf-8')
         # Encode the current time into the file name for FIFO sorting.  The
         # file name consists of two parts separated by a '+': the received
         # time for this message (i.e. when it first showed up on this system)
         # and the sha hex digest.
-        filebase = repr(now) + '+' + hashlib.sha1(hashfood).hexdigest()
+        filebase = now + '+' + hashlib.sha1(hashfood).hexdigest()
         filename = os.path.join(self.queue_directory, filebase + '.pck')
         tmpfile = filename + '.tmp'
         # Always add the metadata schema version number
         data['version'] = config.QFILE_SCHEMA_VERSION
         # Filter out volatile entries.  Use .keys() so that we can mutate the
         # dictionary during the iteration.
-        for k in data.keys():
+        for k in list(data):
             if k.startswith('_'):
                 del data[k]
         # We have to tell the dequeue() method whether to parse the message
         # object or not.
         data['_parsemsg'] = (protocol == 0)
         # Write to the pickle file the message object and metadata.
-        with open(tmpfile, 'w') as fp:
+        with open(tmpfile, 'wb') as fp:
             fp.write(msgsave)
             cPickle.dump(data, fp, protocol)
             fp.flush()
@@ -156,7 +156,7 @@
         filename = os.path.join(self.queue_directory, filebase + '.pck')
         backfile = os.path.join(self.queue_directory, filebase + '.bak')
         # Read the message object and metadata.
-        with open(filename) as fp:
+        with open(filename, 'rb') as fp:
             # Move the file to the backup file name for processing.  If this
             # process crashes uncleanly the .bak file will be used to
             # re-instate the .pck file in order to try again.
@@ -207,13 +207,13 @@
             # Throw out any files which don't match our bitrange.  BAW: test
             # performance and end-cases of this algorithm.  MAS: both
             # comparisons need to be <= to get complete range.
-            if lower is None or (lower <= long(digest, 16) <= upper):
+            if lower is None or (lower <= int(digest, 16) <= upper):
                 key = float(when)
                 while key in times:
                     key += DELTA
                 times[key] = filebase
         # FIFO sort
-        return [times[key] for key in sorted(times)]
+        return [times[k] for k in sorted(times)]
 
     def recover_backup_files(self):
         """See `ISwitchboard`."""
@@ -228,7 +228,8 @@
             dst = os.path.join(self.queue_directory, filebase + '.pck')
             with open(src, 'rb+') as fp:
                 try:
-                    msg = cPickle.load(fp)
+                    # Throw away the message object.
+                    cPickle.load(fp)
                     data_pos = fp.tell()
                     data = cPickle.load(fp)
                 except Exception as error:

=== modified file 'src/mailman/core/tests/test_runner.py'
--- src/mailman/core/tests/test_runner.py	2014-12-11 02:49:39 +0000
+++ src/mailman/core/tests/test_runner.py	2014-12-22 17:38:14 +0000
@@ -70,7 +70,7 @@
 Message-ID: <ant>
 
 """)
-        config.switchboards['in'].enqueue(msg, listname='test@example.com')
+        config.switchboards['in'].enqueue(msg, listid='test.example.com')
         with event_subscribers(self._got_event):
             runner.run()
         # We should now have exactly one event, which will contain the
@@ -81,7 +81,7 @@
         self.assertTrue(isinstance(event, RunnerCrashEvent))
         self.assertEqual(event.mailing_list, self._mlist)
         self.assertEqual(event.message['message-id'], '<ant>')
-        self.assertEqual(event.metadata['listname'], 'test@example.com')
+        self.assertEqual(event.metadata['listid'], 'test.example.com')
         self.assertTrue(isinstance(event.error, RuntimeError))
         self.assertEqual(str(event.error), 'borked')
         self.assertTrue(isinstance(event.runner, CrashingRunner))

=== modified file 'src/mailman/database/sqlite.py'
--- src/mailman/database/sqlite.py	2014-09-23 13:09:45 +0000
+++ src/mailman/database/sqlite.py	2014-12-22 17:38:14 +0000
@@ -28,7 +28,7 @@
 import os
 
 from mailman.database.base import SABaseDatabase
-from urlparse import urlparse
+from six.moves.urllib_parse import urlparse
 
 
 

=== modified file 'src/mailman/email/message.py'
--- src/mailman/email/message.py	2014-12-11 02:49:39 +0000
+++ src/mailman/email/message.py	2014-12-22 17:38:14 +0000
@@ -55,6 +55,32 @@
         self.__version__ = VERSION
         email.message.Message.__init__(self)
 
+<<<<<<< TREE
+=======
+    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]
+
+>>>>>>> MERGE-SOURCE
     # BAW: For debugging w/ bin/dumpdb.  Apparently pprint uses repr.
     def __repr__(self):
         return self.__str__()
@@ -149,8 +175,8 @@
         subject = ('(no subject)' if subject is None else subject)
         if text is not None:
             self.set_payload(text.encode(charset), charset)
-        self['Subject'] = Header(subject.encode(charset), charset,
-                                 header_name='Subject', errors='replace')
+        self['Subject'] = Header(
+            subject, charset, header_name='Subject', errors='replace')
         self['From'] = sender
         if isinstance(recipients, (list, set, tuple)):
             self['To'] = COMMASPACE.join(recipients)
@@ -198,7 +224,7 @@
             reduced_list_headers=True,
             )
         if mlist is not None:
-            enqueue_kws['listname'] = mlist.fqdn_listname
+            enqueue_kws['listid'] = mlist.list_id
         enqueue_kws.update(_kws)
         virginq.enqueue(self, **enqueue_kws)
 
@@ -227,7 +253,7 @@
         virginq = config.switchboards['virgin']
         # The message metadata better have a `recip' attribute
         virginq.enqueue(self,
-                        listname=mlist.fqdn_listname,
+                        listid=mlist.list_id,
                         recipients=self.recipients,
                         nodecorate=True,
                         reduced_list_headers=True,

=== modified file 'src/mailman/handlers/acknowledge.py'
--- src/mailman/handlers/acknowledge.py	2014-01-01 14:59:42 +0000
+++ src/mailman/handlers/acknowledge.py	2014-12-22 17:38:14 +0000
@@ -67,14 +67,13 @@
         language = (language_manager[msgdata['lang']]
                     if 'lang' in msgdata
                     else member.preferred_language)
-        charset = language_manager[language.code].charset
         # Now get the acknowledgement template.
         display_name = mlist.display_name
         text = make('postack.txt',
                     mailing_list=mlist,
                     language=language.code,
                     wrap=False,
-                    subject=oneline(original_subject, charset),
+                    subject=oneline(original_subject, in_unicode=True),
                     list_name=mlist.list_name,
                     display_name=display_name,
                     listinfo_url=mlist.script_url('listinfo'),

=== modified file 'src/mailman/handlers/cook_headers.py'
--- src/mailman/handlers/cook_headers.py	2014-12-09 11:25:45 +0000
+++ src/mailman/handlers/cook_headers.py	2014-12-22 17:38:14 +0000
@@ -27,8 +27,7 @@
 
 import re
 
-from email.errors import HeaderParseError
-from email.header import Header, decode_header, make_header
+from email.header import Header
 from email.utils import parseaddr, formataddr, getaddresses
 from zope.interface import implementer
 
@@ -78,13 +77,6 @@
     msgdata['original_sender'] = msg.sender
     # VirginRunner sets _fasttrack for internally crafted messages.
     fasttrack = msgdata.get('_fasttrack')
-    if not msgdata.get('isdigest') and not fasttrack:
-        try:
-            prefix_subject(mlist, msg, msgdata)
-        except (UnicodeError, ValueError):
-            # TK: Sometimes subject header is not MIME encoded for 8bit
-            # simply abort prefixing.
-            pass
     # Add Precedence: and other useful headers.  None of these are standard
     # and finding information on some of them are fairly difficult.  Some are
     # just common practice, and we'll add more here as they become necessary.
@@ -171,114 +163,6 @@
 
 
 
-def prefix_subject(mlist, msg, msgdata):
-    """Maybe add a subject prefix.
-
-    Add the subject prefix unless the message is a digest or is being fast
-    tracked (e.g. internally crafted, delivered to a single user such as the
-    list admin).
-    """
-    if not mlist.subject_prefix.strip():
-        return
-    prefix = mlist.subject_prefix
-    subject = msg.get('subject', '')
-    # Try to figure out what the continuation_ws is for the header
-    if isinstance(subject, Header):
-        lines = str(subject).splitlines()
-    else:
-        lines = subject.splitlines()
-    ws = '\t'
-    if len(lines) > 1 and lines[1] and lines[1][0] in ' \t':
-        ws = lines[1][0]
-    msgdata['original_subject'] = subject
-    # The subject may be multilingual but we take the first charset as major
-    # one and try to decode.  If it is decodable, returned subject is in one
-    # line and cset is properly set.  If fail, subject is mime-encoded and
-    # cset is set as us-ascii.  See detail for ch_oneline() (CookHeaders one
-    # line function).
-    subject, cset = ch_oneline(subject)
-    # TK: Python interpreter has evolved to be strict on ascii charset code
-    # range.  It is safe to use unicode string when manupilating header
-    # contents with re module.  It would be best to return unicode in
-    # ch_oneline() but here is temporary solution.
-    subject = unicode(subject, cset)
-    # If the subject_prefix contains '%d', it is replaced with the
-    # mailing list sequential number.  Sequential number format allows
-    # '%d' or '%05d' like pattern.
-    prefix_pattern = re.escape(prefix)
-    # unescape '%' :-<
-    prefix_pattern = '%'.join(prefix_pattern.split(r'\%'))
-    p = re.compile('%\d*d')
-    if p.search(prefix, 1):
-        # prefix have number, so we should search prefix w/number in subject.
-        # Also, force new style.
-        prefix_pattern = p.sub(r'\s*\d+\s*', prefix_pattern)
-    subject = re.sub(prefix_pattern, '', subject)
-    rematch = re.match('((RE|AW|SV|VS)(\[\d+\])?:\s*)+', subject, re.I)
-    if rematch:
-        subject = subject[rematch.end():]
-        recolon = 'Re:'
-    else:
-        recolon = ''
-    # At this point, subject may become null if someone post mail with
-    # subject: [subject prefix]
-    if subject.strip() == '':
-        subject = _('(no subject)')
-        cset = mlist.preferred_language.charset
-    # and substitute %d in prefix with post_id
-    try:
-        prefix = prefix % mlist.post_id
-    except TypeError:
-        pass
-    # Get the header as a Header instance, with proper unicode conversion
-    if not recolon:
-        h = uheader(mlist, prefix, 'Subject', continuation_ws=ws)
-    else:
-        h = uheader(mlist, prefix, 'Subject', continuation_ws=ws)
-        h.append(recolon)
-    # TK: Subject is concatenated and unicode string.
-    subject = subject.encode(cset, 'replace')
-    h.append(subject, cset)
-    del msg['subject']
-    msg['Subject'] = h
-    ss = uheader(mlist, recolon, 'Subject', continuation_ws=ws)
-    ss.append(subject, cset)
-    msgdata['stripped_subject'] = ss
-
-
-
-def ch_oneline(headerstr):
-    # Decode header string in one line and convert into single charset.
-    # Return (string, cset) tuple as check for failure.
-    try:
-        d = decode_header(headerstr)
-        # At this point, we should rstrip() every string because some
-        # MUA deliberately add trailing spaces when composing return
-        # message.
-        d = [(s.rstrip(), c) for (s, c) in d]
-        # Find all charsets in the original header.  We use 'utf-8' rather
-        # than using the first charset (in mailman 2.1.x) if multiple
-        # charsets are used.
-        csets = []
-        for (s, c) in d:
-            if c and c not in csets:
-                csets.append(c)
-        if len(csets) == 0:
-            cset = 'us-ascii'
-        elif len(csets) == 1:
-            cset = csets[0]
-        else:
-            cset = 'utf-8'
-        h = make_header(d)
-        ustr = unicode(h)
-        oneline = ''.join(ustr.splitlines())
-        return oneline.encode(cset, 'replace'), cset
-    except (LookupError, UnicodeError, ValueError, HeaderParseError):
-        # possibly charset problem. return with undecoded string in one line.
-        return ''.join(headerstr.splitlines()), 'us-ascii'
-
-
-
 @implementer(IHandler)
 class CookHeaders:
     """Modify message headers."""

=== modified file 'src/mailman/handlers/decorate.py'
--- src/mailman/handlers/decorate.py	2014-04-14 16:14:13 +0000
+++ src/mailman/handlers/decorate.py	2014-12-22 17:38:14 +0000
@@ -31,15 +31,14 @@
 import logging
 
 from email.mime.text import MIMEText
-from urllib2 import URLError
-from zope.component import getUtility
-from zope.interface import implementer
-
 from mailman.core.i18n import _
 from mailman.email.message import Message
 from mailman.interfaces.handler import IHandler
 from mailman.interfaces.templates import ITemplateLoader
 from mailman.utilities.string import expand
+from six.moves.urllib_error import URLError
+from zope.component import getUtility
+from zope.interface import implementer
 
 
 log = logging.getLogger('mailman.error')

=== modified file 'src/mailman/handlers/docs/acknowledge.rst'
--- src/mailman/handlers/docs/acknowledge.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/acknowledge.rst	2014-12-22 17:38:14 +0000
@@ -113,9 +113,9 @@
     1
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg           : False
-    listname            : test@example.com
+    listid              : test.example.com
     nodecorate          : True
-    recipients          : set([u'aperson@example.com'])
+    recipients          : {'aperson@example.com'}
     reduced_list_headers: True
     ...
     >>> print(messages[0].msg.as_string())
@@ -150,9 +150,9 @@
     1
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg           : False
-    listname            : test@example.com
+    listid              : test.example.com
     nodecorate          : True
-    recipients          : set([u'aperson@example.com'])
+    recipients          : {'aperson@example.com'}
     reduced_list_headers: True
     ...
     >>> print(messages[0].msg.as_string())

=== modified file 'src/mailman/handlers/docs/avoid-duplicates.rst'
--- src/mailman/handlers/docs/avoid-duplicates.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/avoid-duplicates.rst	2014-12-22 17:38:14 +0000
@@ -71,7 +71,7 @@
     >>> msgdata = recips.copy()
     >>> handler.process(mlist, msg, msgdata)
     >>> sorted(msgdata['recipients'])
-    [u'aperson@example.com', u'bperson@example.com']
+    ['aperson@example.com', 'bperson@example.com']
     >>> print(msg.as_string())
     From: Claire Person <cperson@example.com>
     <BLANKLINE>
@@ -89,7 +89,7 @@
     >>> msgdata = recips.copy()
     >>> handler.process(mlist, msg, msgdata)
     >>> sorted(msgdata['recipients'])
-    [u'bperson@example.com']
+    ['bperson@example.com']
     >>> print(msg.as_string())
     From: Claire Person <cperson@example.com>
     CC: aperson@example.com
@@ -109,7 +109,7 @@
     >>> msgdata = recips.copy()
     >>> handler.process(mlist, msg, msgdata)
     >>> sorted(msgdata['recipients'])
-    [u'aperson@example.com', u'bperson@example.com']
+    ['aperson@example.com', 'bperson@example.com']
     >>> print(msg.as_string())
     From: Claire Person <cperson@example.com>
     CC: bperson@example.com
@@ -128,7 +128,7 @@
     >>> msgdata = recips.copy()
     >>> handler.process(mlist, msg, msgdata)
     >>> sorted(msgdata['recipients'])
-    [u'bperson@example.com']
+    ['bperson@example.com']
     >>> print(msg.as_string())
     From: Claire Person <cperson@example.com>
     To: aperson@example.com
@@ -147,7 +147,7 @@
     >>> msgdata = recips.copy()
     >>> handler.process(mlist, msg, msgdata)
     >>> sorted(msgdata['recipients'])
-    [u'bperson@example.com']
+    ['bperson@example.com']
     >>> print(msg.as_string())
     From: Claire Person <cperson@example.com>
     Resent-To: aperson@example.com
@@ -166,7 +166,7 @@
     >>> msgdata = recips.copy()
     >>> handler.process(mlist, msg, msgdata)
     >>> sorted(msgdata['recipients'])
-    [u'bperson@example.com']
+    ['bperson@example.com']
     >>> print(msg.as_string())
     From: Claire Person <cperson@example.com>
     Resent-Cc: aperson@example.com

=== modified file 'src/mailman/handlers/docs/digests.rst'
--- src/mailman/handlers/docs/digests.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/digests.rst	2014-12-22 17:38:14 +0000
@@ -82,11 +82,13 @@
     >>> mlist.digest_size_threshold = 1
     >>> mlist.volume = 2
     >>> mlist.next_digest_number = 10
+    >>> digest_path = os.path.join(mlist.data_path, 'digest.mmdf')
     >>> size = 0
     >>> for msg in message_factory:
     ...     process(mlist, msg, {})
-    ...     size += len(str(msg))
-    ...     if size >= mlist.digest_size_threshold * 1024:
+    ...     # When the digest reaches the proper size, it is renamed.  So we
+    ...     # can break out of this list when the file disappears.
+    ...     if not os.path.exists(digest_path):
     ...         break
 
     >>> sum(1 for msg in digest_mbox(mlist))

=== modified file 'src/mailman/handlers/docs/file-recips.rst'
--- src/mailman/handlers/docs/file-recips.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/file-recips.rst	2014-12-22 17:38:14 +0000
@@ -34,26 +34,6 @@
     recipients: 7
 
 
-Missing file
-============
-
-The include file must live inside the list's data directory, under the name
-``members.txt``.  If the file doesn't exist, the list of recipients will be
-empty.
-
-    >>> import os
-    >>> file_path = os.path.join(mlist.data_path, 'members.txt')
-    >>> open(file_path)
-    Traceback (most recent call last):
-    ...
-    IOError: [Errno ...]
-    No such file or directory: u'.../_xtest@example.com/members.txt'
-    >>> msgdata = {}
-    >>> handler.process(mlist, msg, msgdata)
-    >>> dump_list(msgdata['recipients'])
-    *Empty*
-
-
 Existing file
 =============
 
@@ -61,16 +41,15 @@
 addresses are returned as the set of recipients.
 ::
 
-    >>> fp = open(file_path, 'w')
-    >>> try:
+    >>> import os
+    >>> file_path = os.path.join(mlist.data_path, 'members.txt')
+    >>> with open(file_path, 'w', encoding='utf-8') as fp:
     ...     print('bperson@example.com', file=fp)
     ...     print('cperson@example.com', file=fp)
     ...     print('dperson@example.com', file=fp)
     ...     print('eperson@example.com', file=fp)
     ...     print('fperson@example.com', file=fp)
     ...     print('gperson@example.com', file=fp)
-    ... finally:
-    ...     fp.close()
 
     >>> msgdata = {}
     >>> handler.process(mlist, msg, msgdata)

=== modified file 'src/mailman/handlers/docs/filtering.rst'
--- src/mailman/handlers/docs/filtering.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/filtering.rst	2014-12-22 17:38:14 +0000
@@ -26,6 +26,8 @@
 A simple filtering setting will just search the content types of the messages
 parts, discarding all parts with a matching MIME type.  If the message's outer
 content type matches the filter, the entire message will be discarded.
+However, if we turn off content filtering altogether, then the handler
+short-circuits.
 ::
 
     >>> from mailman.interfaces.mime import FilterAction
@@ -42,14 +44,6 @@
     ... """)
 
     >>> process = config.handlers['mime-delete'].process
-    >>> process(mlist, msg, {})
-    Traceback (most recent call last):
-    ...
-    DiscardMessage: The message's content type was explicitly disallowed
-
-However, if we turn off content filtering altogether, then the handler
-short-circuits.
-
     >>> mlist.filter_content = False
     >>> msgdata = {}
     >>> process(mlist, msg, msgdata)
@@ -74,15 +68,15 @@
     MIME-Version: 1.0
     <BLANKLINE>
     xxxxx
-    >>> msgdata
-    {u'isdigest': True}
+    >>> dump_msgdata(msgdata)
+    isdigest: True
 
 
 Simple multipart filtering
 ==========================
 
-If one of the subparts in a multipart message matches the filter type, then
-just that subpart will be stripped.
+If one of the subparts in a ``multipart`` message matches the filter type,
+then just that subpart will be stripped.
 ::
 
     >>> msg = message_from_string("""\
@@ -241,8 +235,8 @@
     >>> try:
     ...     print("""\
     ... import sys
-    ... print 'Converted text/html to text/plain'
-    ... print 'Filename:', sys.argv[1]
+    ... print('Converted text/html to text/plain')
+    ... print('Filename:', sys.argv[1])
     ... """, file=fp)
     ... finally:
     ...     fp.close()

=== modified file 'src/mailman/handlers/docs/nntp.rst'
--- src/mailman/handlers/docs/nntp.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/nntp.rst	2014-12-22 17:38:14 +0000
@@ -63,5 +63,5 @@
 
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg: False
-    listname : test@example.com
+    listid   : test.example.com
     version  : 3

=== modified file 'src/mailman/handlers/docs/replybot.rst'
--- src/mailman/handlers/docs/replybot.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/replybot.rst	2014-12-22 17:38:14 +0000
@@ -49,9 +49,9 @@
 
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg           : False
-    listname            : _xtest@example.com
+    listid              : _xtest.example.com
     nodecorate          : True
-    recipients          : set([u'aperson@example.com'])
+    recipients          : {'aperson@example.com'}
     reduced_list_headers: True
     version             : 3
 
@@ -141,9 +141,9 @@
 
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg           : False
-    listname            : _xtest@example.com
+    listid              : _xtest.example.com
     nodecorate          : True
-    recipients          : set([u'asystem@example.com'])
+    recipients          : {'asystem@example.com'}
     reduced_list_headers: True
     version             : 3
 

=== modified file 'src/mailman/handlers/docs/rfc-2369.rst'
--- src/mailman/handlers/docs/rfc-2369.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/rfc-2369.rst	2014-12-22 17:38:14 +0000
@@ -13,7 +13,7 @@
 ..
     This is a helper function for the following section.
     >>> def list_headers(msg, only=None):
-    ...     if isinstance(only, basestring):
+    ...     if isinstance(only, str):
     ...         only = (only.lower(),)
     ...     elif only is None:
     ...         only = set(header.lower() for header in msg.keys()

=== modified file 'src/mailman/handlers/docs/subject-munging.rst'
--- src/mailman/handlers/docs/subject-munging.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/subject-munging.rst	2014-12-22 17:38:14 +0000
@@ -1,44 +1,42 @@
-===============
-Subject munging
-===============
+================
+Subject prefixes
+================
 
-Messages that flow through the global pipeline get their headers *cooked*,
-which basically means that their headers go through several mostly unrelated
-transformations.  Some headers get added, others get changed.  Some of these
-changes depend on mailing list settings and others depend on how the message
-is getting sent through the system.  We'll take things one-by-one.
+Mailing lists can define a *subject prefix* which gets added to the front of
+any ``Subject`` text.  This can be used to quickly identify which mailing list
+the message was posted to.
 
     >>> mlist = create_list('test@example.com')
 
+The default list style gives the mailing list a default prefix.
+
+    >>> print(mlist.subject_prefix)
+    [Test]
+
+This can be changed to anything, but typically ends with a trailing space.
+
+    >>> mlist.subject_prefix = '[XTest] '
+    >>> process = config.handlers['subject-prefix'].process
+
+
+No Subject
+==========
+
+If the original message has no ``Subject``, then a canned one is used.
+
+    >>> msg = message_from_string("""\
+    ... From: aperson@example.com
+    ...
+    ... A message of great import.
+    ... """)
+    >>> process(mlist, msg, {})
+    >>> print(msg['subject'])
+    [XTest] (no subject)
+
 
 Inserting a prefix
 ==================
 
-Another thing header cooking does is *munge* the ``Subject`` header by
-inserting the subject prefix for the list at the front.  If there's no subject
-header in the original message, Mailman uses a canned default.  In order to do
-subject munging, a mailing list must have a preferred language.
-::
-
-    >>> mlist.subject_prefix = '[XTest] '
-    >>> mlist.preferred_language = 'en'
-    >>> msg = message_from_string("""\
-    ... From: aperson@example.com
-    ...
-    ... A message of great import.
-    ... """)
-    >>> msgdata = {}
-
-    >>> from mailman.handlers.cook_headers import process
-    >>> process(mlist, msg, msgdata)
-
-The original subject header is stored in the message metadata.
-
-    >>> msgdata['original_subject']
-    u''
-    >>> print(msg['subject'])
-    [XTest] (no subject)
-
 If the original message had a ``Subject`` header, then the prefix is inserted
 at the beginning of the header's value.
 
@@ -50,35 +48,13 @@
     ... """)
     >>> msgdata = {}
     >>> process(mlist, msg, msgdata)
+    >>> print(msg['subject'])
+    [XTest] Something important
+
+The original ``Subject`` is available in the metadata.
+
     >>> print(msgdata['original_subject'])
     Something important
-    >>> print(msg['subject'])
-    [XTest] Something important
-
-``Subject`` headers are not munged for digest messages.
-    
-    >>> msg = message_from_string("""\
-    ... From: aperson@example.com
-    ... Subject: Something important
-    ...
-    ... A message of great import.
-    ... """)
-    >>> process(mlist, msg, dict(isdigest=True))
-    >>> print(msg['subject'])
-    Something important
-
-Nor are they munged for *fast tracked* messages, which are generally defined
-as messages that Mailman crafts internally.
-
-    >>> msg = message_from_string("""\
-    ... From: aperson@example.com
-    ... Subject: Something important
-    ...
-    ... A message of great import.
-    ... """)
-    >>> process(mlist, msg, dict(_fasttrack=True))
-    >>> print(msg['subject'])
-    Something important
 
 If a ``Subject`` header already has a prefix, usually following a ``Re:``
 marker, another one will not be added but the prefix will be moved to the
@@ -95,8 +71,7 @@
     [XTest] Re: Something important
 
 If the ``Subject`` header has a prefix at the front of the header text, that's
-where it will stay.  This is called *new style* prefixing and is the only
-option available in Mailman 3.
+where it will stay.
 
     >>> msg = message_from_string("""\
     ... From: aperson@example.com
@@ -122,10 +97,10 @@
     ...
     ... """)
     >>> process(mlist, msg, {})
-    >>> print(msg['subject'])
+    >>> print(msg['subject'].encode())
     [XTest] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
-    >>> unicode(msg['subject'])
-    u'[XTest] \u30e1\u30fc\u30eb\u30de\u30f3'
+    >>> print(str(msg['subject']))
+    [XTest] メールマン
 
 
 Prefix numbers
@@ -178,10 +153,10 @@
     ...
     ... """)
     >>> process(mlist, msg, {})
-    >>> print(msg['subject'])
+    >>> print(msg['subject'].encode())
     [XTest 456] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
-    >>> unicode(msg['subject'])
-    u'[XTest 456] \u30e1\u30fc\u30eb\u30de\u30f3'
+    >>> print(msg['subject'])
+    [XTest 456] メールマン
 
 Even more fun is when the internationalized ``Subject`` header already has a
 prefix, possibly with a different posting number.
@@ -191,13 +166,10 @@
     ...
     ... """)
     >>> process(mlist, msg, {})
-    >>> print(msg['subject'])
+    >>> print(msg['subject'].encode())
     [XTest 456] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
-
-..
- # XXX This requires Python email patch #1681333 to succeed.
- #    >>> unicode(msg['subject'])
- #    u'[XTest 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3'
+    >>> print(msg['subject'])
+    [XTest 456] Re: メールマン
 
 As before, old style subject prefixes are re-ordered.
 
@@ -206,14 +178,11 @@
     ...
     ... """)
     >>> process(mlist, msg, {})
-    >>> print(msg['subject'])
+    >>> print(msg['subject'].encode())
     [XTest 456] Re:
       =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
-
-..
- # XXX This requires Python email patch #1681333 to succeed.
- #    >>> unicode(msg['subject'])
- #    u'[XTest 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3'
+    >>> print(msg['subject'])
+    [XTest 456]  Re: メールマン
 
 
 In this test case, we get an extra space between the prefix and the original

=== modified file 'src/mailman/handlers/docs/tagger.rst'
--- src/mailman/handlers/docs/tagger.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/tagger.rst	2014-12-22 17:38:14 +0000
@@ -55,7 +55,7 @@
     <BLANKLINE>
     <BLANKLINE>
     >>> msgdata['topichits']
-    [u'bar fight']
+    ['bar fight']
 
 
 Scanning body lines
@@ -114,7 +114,7 @@
     Keywords: barbaz
     <BLANKLINE>
     >>> msgdata['topichits']
-    [u'bar fight']
+    ['bar fight']
 
 However, scanning stops at the first body line that doesn't look like a
 header.
@@ -161,7 +161,7 @@
     >>> print(msg['x-topics'])
     bar fight
     >>> msgdata['topichits']
-    [u'bar fight']
+    ['bar fight']
 
 
 Scanning sub-parts
@@ -175,14 +175,14 @@
     ... Subject: Was
     ... Keywords: Raw
     ... Content-Type: multipart/alternative; boundary="BOUNDARY"
-    ... 
+    ...
     ... --BOUNDARY
     ... From: sabo
     ... To: obas
-    ... 
+    ...
     ... Subject: farbaw
     ... Keywords: barbaz
-    ... 
+    ...
     ... --BOUNDARY--
     ... """)
     >>> msgdata = {}
@@ -203,7 +203,7 @@
     --BOUNDARY--
     <BLANKLINE>
     >>> msgdata['topichits']
-    [u'bar fight']
+    ['bar fight']
 
 But the tagger will not descend into non-text parts.
 
@@ -211,23 +211,23 @@
     ... Subject: Was
     ... Keywords: Raw
     ... Content-Type: multipart/alternative; boundary=BOUNDARY
-    ... 
-    ... --BOUNDARY
-    ... From: sabo
-    ... To: obas
-    ... Content-Type: message/rfc822
-    ... 
-    ... Subject: farbaw
-    ... Keywords: barbaz
-    ... 
-    ... --BOUNDARY
-    ... From: sabo
-    ... To: obas
-    ... Content-Type: message/rfc822
-    ... 
-    ... Subject: farbaw
-    ... Keywords: barbaz
-    ... 
+    ...
+    ... --BOUNDARY
+    ... From: sabo
+    ... To: obas
+    ... Content-Type: message/rfc822
+    ...
+    ... Subject: farbaw
+    ... Keywords: barbaz
+    ...
+    ... --BOUNDARY
+    ... From: sabo
+    ... To: obas
+    ... Content-Type: message/rfc822
+    ...
+    ... Subject: farbaw
+    ... Keywords: barbaz
+    ...
     ... --BOUNDARY--
     ... """)
     >>> msgdata = {}

=== modified file 'src/mailman/handlers/docs/to-outgoing.rst'
--- src/mailman/handlers/docs/to-outgoing.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/handlers/docs/to-outgoing.rst	2014-12-22 17:38:14 +0000
@@ -37,6 +37,6 @@
     _parsemsg: False
     bar      : 2
     foo      : 1
-    listname : test@example.com
+    listid   : test.example.com
     verp     : True
     version  : 3

=== modified file 'src/mailman/handlers/mime_delete.py'
--- src/mailman/handlers/mime_delete.py	2014-11-20 01:29:44 +0000
+++ src/mailman/handlers/mime_delete.py	2014-12-22 17:38:14 +0000
@@ -245,7 +245,7 @@
         filename = tempfile.mktemp('.html')
         fp = open(filename, 'w')
         try:
-            fp.write(subpart.get_payload(decode=True))
+            fp.write(subpart.get_payload())
             fp.close()
             cmd = os.popen(config.HTML_TO_PLAIN_TEXT_COMMAND %
                            {'filename': filename})

=== added file 'src/mailman/handlers/subject_prefix.py'
--- src/mailman/handlers/subject_prefix.py	1970-01-01 00:00:00 +0000
+++ src/mailman/handlers/subject_prefix.py	2014-12-22 17:38:14 +0000
@@ -0,0 +1,187 @@
+# 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/>.
+
+"""Subject header prefix munging."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'SubjectPrefix',
+    ]
+
+
+import re
+
+from email.header import Header, make_header, decode_header
+from mailman.core.i18n import _
+from mailman.interfaces.handler import IHandler
+from zope.interface import implementer
+
+
+RE_PATTERN = '((RE|AW|SV|VS)(\[\d+\])?:\s*)+'
+ASCII_CHARSETS = (None, 'ascii', 'us-ascii')
+EMPTYSTRING = ''
+
+
+
+def ascii_header(mlist, msgdata, subject, prefix, prefix_pattern, ws):
+    if mlist.preferred_language.charset not in ASCII_CHARSETS:
+        return None
+    for chunk, charset in decode_header(subject.encode()):
+        if charset not in ASCII_CHARSETS:
+            return None
+    subject_text = EMPTYSTRING.join(str(subject).splitlines())
+    rematch = re.match(RE_PATTERN, subject_text, re.I)
+    if rematch:
+        subject_text = subject_text[rematch.end():]
+        recolon = 'Re: '
+    else:
+        recolon = ''
+    # At this point, the subject may become null if someone posted mail
+    # with "Subject: [subject prefix]".
+    if subject_text.strip() == '':
+        with _.using(mlist.preferred_language.code):
+            subject_text = _('(no subject)')
+    else:
+        subject_text = re.sub(prefix_pattern, '', subject_text)
+    msgdata['stripped_subject'] = subject_text
+    lines = subject_text.splitlines()
+    first_line = [lines[0]]
+    if recolon:
+        first_line.insert(0, recolon)
+    if prefix:
+        first_line.insert(0, prefix)
+    subject_text = EMPTYSTRING.join(first_line)
+    return Header(subject_text, continuation_ws=ws)
+
+
+def all_same_charset(mlist, msgdata, subject, prefix, prefix_pattern, ws):
+    list_charset = mlist.preferred_language.charset
+    chunks = []
+    for chunk, charset in decode_header(subject.encode()):
+        if charset is None:
+            charset = 'us-ascii'
+        chunks.append(chunk.decode(charset))
+        if charset != list_charset:
+            return None
+    subject_text = EMPTYSTRING.join(chunks)
+    rematch = re.match(RE_PATTERN, subject_text, re.I)
+    if rematch:
+        subject_text = subject_text[rematch.end():]
+        recolon = 'Re: '
+    else:
+        recolon = ''
+    # At this point, the subject may become null if someone posted mail
+    # with "Subject: [subject prefix]".
+    if subject_text.strip() == '':
+        with _.push(mlist.preferred_language.code):
+            subject_text = _('(no subject)')
+    else:
+        subject_text = re.sub(prefix_pattern, '', subject_text)
+    msgdata['stripped_subject'] = subject_text
+    lines = subject_text.splitlines()
+    first_line = [lines[0]]
+    if recolon:
+        first_line.insert(0, recolon)
+    if prefix:
+        first_line.insert(0, prefix)
+    subject_text = EMPTYSTRING.join(first_line)
+    return Header(subject_text, charset=list_charset, continuation_ws=ws)
+
+
+def mixed_charsets(mlist, msgdata, subject, prefix, prefix_pattern, ws):
+    list_charset = mlist.preferred_language.charset
+    chunks = decode_header(subject.encode())
+    if len(chunks) == 0:
+        with _.push(mlist.preferred_language.code):
+            subject_text = _('(no subject)')
+        chunks = [(prefix, list_charset),
+                  (subject_text, list_charset),
+                  ]
+        return make_header(chunks, continuation_ws=ws)
+    # Only search the first chunk for Re and existing prefix.
+    chunk_text, chunk_charset = chunks[0]
+    if chunk_charset is None:
+        chunk_charset = 'us-ascii'
+    first_text = chunk_text.decode(chunk_charset)
+    first_text = re.sub(prefix_pattern, '', first_text).lstrip()
+    rematch = re.match(RE_PATTERN, first_text, re.I)
+    if rematch:
+        first_text = 'Re: ' + first_text[rematch.end():]
+    chunks[0] = (first_text, chunk_charset)
+    # The subject text stripped of the prefix, for use in the NNTP gateway.
+    msgdata['stripped_subject'] = str(make_header(chunks, continuation_ws=ws))
+    chunks.insert(0, (prefix, list_charset))
+    return make_header(chunks, continuation_ws=ws)
+
+
+
+@implementer(IHandler)
+class SubjectPrefix:
+    """Add a list-specific prefix to the Subject header value."""
+
+    name = 'subject-prefix'
+    description = _('Add a list-specific prefix to the Subject header value.')
+
+    def process(self, mlist, msg, msgdata):
+        """See `IHandler`."""
+        if msgdata.get('isdigest') or msgdata.get('_fasttrack'):
+            return
+        prefix = mlist.subject_prefix
+        if not prefix.strip():
+            return
+        subject = msg.get('subject', '')
+        # Turn the value into a Header instance and try to figure out what
+        # continuation whitespace is being used.
+        # Save the original Subject.
+        msgdata['original_subject'] = subject
+        if isinstance(subject, Header):
+            subject_text = str(subject)
+        else:
+            subject = make_header(decode_header(subject))
+            subject_text = str(subject)
+        lines = subject_text.splitlines()
+        ws = '\t'
+        if len(lines) > 1 and lines[1] and lines[1][0] in ' \t':
+            ws = lines[1][0]
+        # If the subject_prefix contains '%d', it is replaced with the mailing
+        # list's sequence number.  The sequential number format allows '%d' or
+        # '%05d' like pattern.
+        prefix_pattern = re.escape(prefix)
+        # Unescape '%'.
+        prefix_pattern = '%'.join(prefix_pattern.split(r'\%'))
+        p = re.compile('%\d*d')
+        if p.search(prefix, 1):
+            # The prefix has number, so we should search prefix w/number in
+            # subject.  Also, force new style.
+            prefix_pattern = p.sub(r'\s*\d+\s*', prefix_pattern)
+        # Substitute %d in prefix with post_id
+        try:
+            prefix = prefix % mlist.post_id
+        except TypeError:
+            pass
+        for handler in (ascii_header,
+                        all_same_charset,
+                        mixed_charsets,
+                        ):
+            new_subject = handler(
+                mlist, msgdata, subject, prefix, prefix_pattern, ws)
+            if new_subject is not None:
+                del msg['subject']
+                msg['Subject'] = new_subject
+                return

=== modified file 'src/mailman/handlers/tagger.py'
--- src/mailman/handlers/tagger.py	2014-01-01 14:59:42 +0000
+++ src/mailman/handlers/tagger.py	2014-12-22 17:38:14 +0000
@@ -37,7 +37,7 @@
 
 OR = '|'
 CRNL = '\r\n'
-EMPTYBYTES = b''
+EMPTYSTRING = ''
 NLTAB = '\n\t'
 
 
@@ -104,7 +104,7 @@
     reader = list(email.iterators.body_line_iterator(msg))
     while numlines is None or lineno < numlines:
         try:
-            line = bytes(reader.pop(0))
+            line = reader.pop(0)
         except IndexError:
             break
         # Blank lines don't count
@@ -115,7 +115,7 @@
     # Concatenate those body text lines with newlines, and then create a new
     # message object from those lines.
     p = _ForgivingParser()
-    msg = p.parsestr(EMPTYBYTES.join(lines))
+    msg = p.parsestr(EMPTYSTRING.join(lines))
     return msg.get_all('subject', []) + msg.get_all('keywords', [])
 
 

=== added file 'src/mailman/handlers/tests/test_file_recips.py'
--- src/mailman/handlers/tests/test_file_recips.py	1970-01-01 00:00:00 +0000
+++ src/mailman/handlers/tests/test_file_recips.py	2014-12-22 17:38:14 +0000
@@ -0,0 +1,76 @@
+# 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 file-recips handler."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'TestFileRecips',
+    ]
+
+
+import os
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.config import config
+from mailman.testing.helpers import specialized_message_from_string as mfs
+from mailman.testing.layers import ConfigLayer
+
+
+
+class TestFileRecips(unittest.TestCase):
+    layer = ConfigLayer
+
+    def setUp(self):
+        self._mlist = create_list('test@example.com')
+        self._handler = config.handlers['file-recipients'].process
+        self._msg = mfs("""\
+From: aperson@example.com
+
+A message.
+""")
+
+    def test_file_is_missing(self):
+        # It is not an error for the list's the members.txt file to be
+        # missing.  The missing file is just ignored.
+        msgdata = {}
+        self._handler(self._mlist, self._msg, msgdata)
+        self.assertEqual(msgdata['recipients'], set())
+
+    def test_file_exists(self):
+        # Like above, but the file exists and contains recipients.
+        path = os.path.join(self._mlist.data_path, 'members.txt')
+        with open(path, 'w', encoding='utf-8') as fp:
+            print('bperson@example.com', file=fp)
+            print('cperson@example.com', file=fp)
+            print('dperson@example.com', file=fp)
+            print('eperson@example.com', file=fp)
+            print('fperson@example.com', file=fp)
+            print('gperson@example.com', file=fp)
+        msgdata = {}
+        self._handler(self._mlist, self._msg, msgdata)
+        self.assertEqual(msgdata['recipients'], set((
+            'bperson@example.com',
+            'cperson@example.com',
+            'dperson@example.com',
+            'eperson@example.com',
+            'fperson@example.com',
+            'gperson@example.com',
+            )))

=== added file 'src/mailman/handlers/tests/test_filter.py'
--- src/mailman/handlers/tests/test_filter.py	1970-01-01 00:00:00 +0000
+++ src/mailman/handlers/tests/test_filter.py	2014-12-22 17:38:14 +0000
@@ -0,0 +1,60 @@
+# 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 filter handler."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'TestFilters',
+    ]
+
+
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.config import config
+from mailman.core.errors import DiscardMessage
+from mailman.interfaces.mime import FilterAction
+from mailman.testing.helpers import specialized_message_from_string as mfs
+from mailman.testing.layers import ConfigLayer
+
+
+
+class TestFilters(unittest.TestCase):
+    layer = ConfigLayer
+
+    def setUp(self):
+        self._mlist = create_list('test@example.com')
+
+    def test_discard_when_outer_type_matches(self):
+        # When the outer MIME type of the message matches a filter type, the
+        # entire message is discarded.
+        self._mlist.filter_content = True
+        self._mlist.filter_types = ['image/jpeg']
+        self._mlist.filter_action = FilterAction.discard
+        msg = mfs("""\
+From: aperson@example.com
+Content-Type: image/jpeg
+MIME-Version: 1.0
+
+xxxxx
+""")
+        self.assertRaises(DiscardMessage,
+                          config.handlers['mime-delete'].process,
+                          self._mlist, msg, {})

=== modified file 'src/mailman/handlers/tests/test_recipients.py'
--- src/mailman/handlers/tests/test_recipients.py	2014-11-29 20:10:59 +0000
+++ src/mailman/handlers/tests/test_recipients.py	2014-12-22 17:38:14 +0000
@@ -28,13 +28,14 @@
 
 import unittest
 
-from zope.component import getUtility
 from mailman.app.lifecycle import create_list
 from mailman.config import config
 from mailman.interfaces.member import DeliveryMode, DeliveryStatus, MemberRole
 from mailman.interfaces.usermanager import IUserManager
-from mailman.testing.helpers import specialized_message_from_string as mfs
+from mailman.testing.helpers import (
+    configuration, specialized_message_from_string as mfs)
 from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
 
 
 
@@ -199,23 +200,14 @@
         self._process(self._mlist, self._msg, msgdata)
         self.assertEqual(msgdata['recipients'], set(('noreply@example.com',)))
 
-    def test_site_admin_unicode(self):
-        # Since the config file is read as bytes, the site_owner is also a
-        # bytes and must be converted to unicode when used as a fallback.
+    @configuration('mailman', site_owner='siteadmin@example.com')
+    def test_no_owners_site_owner_fallback(self):
+        # The list has no owners or moderators, but there is a non-default
+        # site owner defined.  That owner gets the message.
         self._cris.unsubscribe()
         self._dave.unsubscribe()
         self.assertEqual(self._mlist.administrators.member_count, 0)
         msgdata = {}
-        # In order to properly mimic the testing environment, use
-        # config.push()/config.pop() directly instead of using the
-        # configuration() context manager.
-        config.push('test_site_admin_unicode', b"""\
-[mailman]
-site_owner: siteadmin@example.com
-""")
-        try:
-            self._process(self._mlist, self._msg, msgdata)
-        finally:
-            config.pop('test_site_admin_unicode')
-        self.assertEqual(len(msgdata['recipients']), 1)
-        self.assertIsInstance(list(msgdata['recipients'])[0], unicode)
+        self._process(self._mlist, self._msg, msgdata)
+        self.assertEqual(msgdata['recipients'],
+                         set(('siteadmin@example.com',)))

=== added file 'src/mailman/handlers/tests/test_subject_prefix.py'
--- src/mailman/handlers/tests/test_subject_prefix.py	1970-01-01 00:00:00 +0000
+++ src/mailman/handlers/tests/test_subject_prefix.py	2014-12-22 17:38:14 +0000
@@ -0,0 +1,132 @@
+# 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 Subject header prefix munging.."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'TestSubjectPrefix',
+    ]
+
+
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.config import config
+from mailman.email.message import Message
+from mailman.testing.layers import ConfigLayer
+
+
+
+class TestSubjectPrefix(unittest.TestCase):
+    layer = ConfigLayer
+
+    def setUp(self):
+        self._mlist = create_list('test@example.com')
+        self._process = config.handlers['subject-prefix'].process
+
+    def test_isdigest(self):
+        # If the message is destined for the digest, the Subject header does
+        # not get touched.
+        msg = Message()
+        msg['Subject'] = 'A test message'
+        self._process(self._mlist, msg, dict(isdigest=True))
+        self.assertEqual(str(msg['subject']), 'A test message')
+
+    def test_fasttrack(self):
+        # Messages internally crafted are 'fast tracked' and don't get their
+        # Subjects prefixed either.
+        msg = Message()
+        msg['Subject'] = 'A test message'
+        self._process(self._mlist, msg, dict(_fasttrack=True))
+        self.assertEqual(str(msg['subject']), 'A test message')
+
+    def test_whitespace_only_prefix(self):
+        # If the Subject prefix only contains whitespace, ignore it.
+        self._mlist.subject_prefix = '    '
+        msg = Message()
+        msg['Subject'] = 'A test message'
+        self._process(self._mlist, msg, dict(_fasttrack=True))
+        self.assertEqual(str(msg['subject']), 'A test message')
+
+    def test_save_original_subject(self):
+        # When the Subject gets prefixed, the original is saved in the message
+        # metadata.
+        msgdata = {}
+        msg = Message()
+        msg['Subject'] = 'A test message'
+        self._process(self._mlist, msg, msgdata)
+        self.assertEqual(msgdata['original_subject'], 'A test message')
+
+    def test_prefix(self):
+        # The Subject gets prefixed.  The prefix gets automatically set by the
+        # list style when the list gets created.
+        msg = Message()
+        msg['Subject'] = 'A test message'
+        self._process(self._mlist, msg, {})
+        self.assertEqual(str(msg['subject']), '[Test] A test message')
+
+    def test_no_double_prefix(self):
+        # Don't add a prefix if the subject already contains one.
+        msg = Message()
+        msg['Subject'] = '[Test] A test message'
+        self._process(self._mlist, msg, {})
+        self.assertEqual(str(msg['subject']), '[Test] A test message')
+
+    def test_re_prefix(self):
+        # The subject has a Re: prefix.  Make sure that gets preserved, but
+        # after the list prefix.
+        msg = Message()
+        msg['Subject'] = 'Re: [Test] A test message'
+        self._process(self._mlist, msg, {})
+        self.assertEqual(str(msg['subject']), '[Test] Re: A test message')
+
+    def test_multiline_subject(self):
+        # The subject appears on multiple lines.
+        msg = Message()
+        msg['Subject'] = '\n A test message'
+        self._process(self._mlist, msg, {})
+        self.assertEqual(str(msg['subject']), '[Test]  A test message')
+
+    def test_i18n_prefix(self):
+        # The Subject header is encoded, but the prefix is still added.
+        msg = Message()
+        msg['Subject'] = '=?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?='
+        self._process(self._mlist, msg, {})
+        subject = msg['subject']
+        self.assertEqual(subject.encode(),
+                         '[Test] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=')
+        self.assertEqual(str(subject), '[Test] メールマン')
+
+    def test_i18n_subject_with_sequential_prefix_and_re(self):
+        # The mailing list defines a sequential prefix, and the original
+        # Subject has a prefix with a different sequence number, *and* it also
+        # contains a Re: prefix.  Make sure the sequence gets updated and all
+        # the bits get put back together in the right order.
+        self._mlist.subject_prefix = '[Test %d]'
+        self._mlist.post_id = 456
+        msg = Message()
+        msg['Subject'] = \
+          '[Test 123] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?='
+        self._process(self._mlist, msg, {})
+        subject = msg['subject']
+        self.assertEqual(
+            subject.encode(),
+            '[Test 456] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=')
+        self.assertEqual(str(subject), '[Test 456] Re: メールマン')

=== modified file 'src/mailman/handlers/to_digest.py'
--- src/mailman/handlers/to_digest.py	2014-11-25 11:15:42 +0000
+++ src/mailman/handlers/to_digest.py	2014-12-22 17:38:14 +0000
@@ -55,7 +55,7 @@
         mailbox_path = os.path.join(mlist.data_path, 'digest.mmdf')
         # Lock the mailbox and append the message.
         with Mailbox(mailbox_path, create=True) as mbox:
-            mbox.add(msg.as_string())
+            mbox.add(msg)
         # Calculate the current size of the mailbox file.  This will not tell
         # us exactly how big the resulting MIME and rfc1153 digest will
         # actually be, but it's the most easily available metric to decide
@@ -75,7 +75,7 @@
             os.rename(mailbox_path, mailbox_dest)
             config.switchboards['digest'].enqueue(
                 Message(),
-                listname=mlist.fqdn_listname,
+                listid=mlist.list_id,
                 digest_path=mailbox_dest,
                 volume=volume,
                 digest_number=digest_number)

=== modified file 'src/mailman/handlers/to_outgoing.py'
--- src/mailman/handlers/to_outgoing.py	2014-01-01 14:59:42 +0000
+++ src/mailman/handlers/to_outgoing.py	2014-12-22 17:38:14 +0000
@@ -47,5 +47,4 @@
 
     def process(self, mlist, msg, msgdata):
         """See `IHandler`."""
-        config.switchboards['out'].enqueue(
-            msg, msgdata, listname=mlist.fqdn_listname)
+        config.switchboards['out'].enqueue(msg, msgdata, listid=mlist.list_id)

=== modified file 'src/mailman/handlers/to_usenet.py'
--- src/mailman/handlers/to_usenet.py	2014-01-01 14:59:42 +0000
+++ src/mailman/handlers/to_usenet.py	2014-12-22 17:38:14 +0000
@@ -65,5 +65,4 @@
                       COMMASPACE.join(error))
             return
         # Put the message in the news runner's queue.
-        config.switchboards['nntp'].enqueue(
-            msg, msgdata, listname=mlist.fqdn_listname)
+        config.switchboards['nntp'].enqueue(msg, msgdata, listid=mlist.list_id)

=== modified file 'src/mailman/model/docs/addresses.rst'
--- src/mailman/model/docs/addresses.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/addresses.rst	2014-12-22 17:38:14 +0000
@@ -205,23 +205,9 @@
     FPERSON@example.com
 
 Because addresses are case-insensitive for all other purposes, you cannot
-create an address that differs only in case.
-
-    >>> user_manager.create_address('fperson@example.com')
-    Traceback (most recent call last):
-    ...
-    ExistingAddressError: FPERSON@example.com
-    >>> user_manager.create_address('fperson@EXAMPLE.COM')
-    Traceback (most recent call last):
-    ...
-    ExistingAddressError: FPERSON@example.com
-    >>> user_manager.create_address('FPERSON@example.com')
-    Traceback (most recent call last):
-    ...
-    ExistingAddressError: FPERSON@example.com
-
-You can get the address using either the lower cased version or case-preserved
-version.  In fact, searching for an address is case insensitive.
+create an address that differs only in case.  You can get the address using
+either the lower cased version or case-preserved version.  In fact, searching
+for an address is case insensitive.
 
     >>> print(user_manager.get_address('fperson@example.com').email)
     fperson@example.com

=== modified file 'src/mailman/model/docs/domains.rst'
--- src/mailman/model/docs/domains.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/domains.rst	2014-12-22 17:38:14 +0000
@@ -108,12 +108,7 @@
             base_url: http://lists.example.net,
             contact_address: postmaster@example.com>
 
-    >>> print(manager['doesnotexist.com'])
-    Traceback (most recent call last):
-    ...
-    KeyError: u'doesnotexist.com'
-
-As with a dictionary, you can also get the domain.  If the domain does not
+As with dictionaries, you can also get the domain.  If the domain does not
 exist, ``None`` or a default is returned.
 ::
 
@@ -128,13 +123,6 @@
     >>> print(manager.get('doesnotexist.com', 'blahdeblah'))
     blahdeblah
 
-Non-existent domains cannot be removed.
-
-    >>> manager.remove('doesnotexist.com')
-    Traceback (most recent call last):
-    ...
-    KeyError: u'doesnotexist.com'
-
 
 Confirmation tokens
 ===================

=== modified file 'src/mailman/model/docs/languages.rst'
--- src/mailman/model/docs/languages.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/languages.rst	2014-12-22 17:38:14 +0000
@@ -62,7 +62,7 @@
     >>> mgr.add('pl', 'iso-8859-2', 'Polish')
     <Language [pl] Polish>
     >>> sorted(mgr.codes)
-    [u'en', u'it', u'pl']
+    ['en', 'it', 'pl']
 
 You can iterate over all the known languages.
 
@@ -89,7 +89,7 @@
     >>> print(mgr['xx'].code)
     Traceback (most recent call last):
     ...
-    KeyError: u'xx'
+    KeyError: 'xx'
     >>> print(mgr.get('it').description)
     Italian
     >>> print(mgr.get('xx'))

=== modified file 'src/mailman/model/docs/listmanager.rst'
--- src/mailman/model/docs/listmanager.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/listmanager.rst	2014-12-22 17:38:14 +0000
@@ -34,22 +34,6 @@
     >>> print(mlist.list_id)
     test.example.com
 
-If you try to create a mailing list with the same name as an existing list,
-you will get an exception.
-
-    >>> list_manager.create('test@example.com')
-    Traceback (most recent call last):
-    ...
-    ListAlreadyExistsError: test@example.com
-
-It is an error to create a mailing list that isn't a fully qualified list name
-(i.e. posting address).
-
-    >>> list_manager.create('foo')
-    Traceback (most recent call last):
-    ...
-    InvalidEmailAddressError: foo
-
 
 Deleting a mailing list
 =======================

=== modified file 'src/mailman/model/docs/mailinglist.rst'
--- src/mailman/model/docs/mailinglist.rst	2014-10-31 03:12:00 +0000
+++ src/mailman/model/docs/mailinglist.rst	2014-12-22 17:38:14 +0000
@@ -114,7 +114,8 @@
 An alternative way of subscribing to a mailing list is as a user with a
 preferred address.  This way the user can change their subscription address
 just by changing their preferred address.
-::
+
+The user must have a preferred address.
 
     >>> from mailman.utilities.datetime import now
     >>> user = user_manager.create_user('dperson@example.com', 'Dave Person')
@@ -122,6 +123,8 @@
     >>> address.verified_on = now()
     >>> user.preferred_address = address
 
+The preferred address is used in the subscription.
+
     >>> mlist.subscribe(user)
     <Member: Dave Person <dperson@example.com> on aardvark@example.com
              as MemberRole.member>
@@ -132,6 +135,10 @@
     <Member: Dave Person <dperson@example.com> on aardvark@example.com
              as MemberRole.member>
 
+If the user's preferred address changes, their subscribed email address also
+changes automatically.
+::
+
     >>> new_address = user.register('dave.person@example.com')
     >>> new_address.verified_on = now()
     >>> user.preferred_address = new_address
@@ -143,31 +150,12 @@
     <Member: dave.person@example.com on aardvark@example.com
              as MemberRole.member>
 
-A user is not allowed to subscribe more than once to the mailing list.
-
-    >>> mlist.subscribe(user)
-    Traceback (most recent call last):
-    ...
-    AlreadySubscribedError: <User "Dave Person" (1) at ...>
-    is already a MemberRole.member of mailing list aardvark@example.com
-
-However, they are allowed to subscribe again with a specific address, even if
-this address is their preferred address.
+A user is allowed to explicitly subscribe again with a specific address, even
+if this address is their preferred address.
 
     >>> mlist.subscribe(user.preferred_address)
     <Member: dave.person@example.com
              on aardvark@example.com as MemberRole.member>
 
-A user cannot subscribe to a mailing list without a preferred address.
-
-    >>> user = user_manager.create_user('eperson@example.com', 'Elly Person')
-    >>> address = list(user.addresses)[0]
-    >>> address.verified_on = now()
-    >>> mlist.subscribe(user)
-    Traceback (most recent call last):
-    ...
-    MissingPreferredAddressError: User must have a preferred address:
-    <User "Elly Person" (2) at ...>
-
 
 .. _`RFC 2369`: http://www.faqs.org/rfcs/rfc2369.html

=== modified file 'src/mailman/model/docs/membership.rst'
--- src/mailman/model/docs/membership.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/membership.rst	2014-12-22 17:38:14 +0000
@@ -2,10 +2,10 @@
 List memberships
 ================
 
-Users represent people in Mailman.  Users control email addresses, and rosters
-are collections of members.  A member gives an email address a role, such as
-`member`, `administrator`, or `moderator`.  Even nonmembers are represented by
-a roster.
+Users represent people in Mailman, members represent subscriptions.  Users
+control email addresses, and rosters are collections of members.  A member
+ties a subscribed email address to a role, such as `member`, `administrator`,
+or `moderator`.  Even non-members are represented by a roster.
 
 Roster sets are collections of rosters and a mailing list has a single roster
 set that contains all its members, regardless of that member's role.
@@ -228,18 +228,6 @@
     fperson@example.com MemberRole.nonmember
 
 
-Double subscriptions
-====================
-
-It is an error to subscribe someone to a list with the same role twice.
-
-    >>> mlist.subscribe(address_1, MemberRole.owner)
-    Traceback (most recent call last):
-    ...
-    AlreadySubscribedError: aperson@example.com is already a MemberRole.owner
-    of mailing list ant@example.com
-
-
 Moderation actions
 ==================
 
@@ -276,7 +264,7 @@
 When a user is subscribed to a mailing list via a specific address they
 control (as opposed to being subscribed with their preferred address), they
 can change their delivery address by setting the appropriate parameter.  Note
-though that the address their changing to must be verified.
+though that the address they're changing to must be verified.
 
     >>> bee = create_list('bee@example.com')
     >>> gwen = user_manager.create_user('gwen@example.com')
@@ -290,20 +278,6 @@
 
     >>> new_address = gwen.register('gperson@example.com')
 
-She wants to change her membership in the `test` mailing list to use her new
-address, but the address is not yet verified.
-
-    >>> gwen_member.address = new_address
-    Traceback (most recent call last):
-    ...
-    UnverifiedAddressError: gperson@example.com
-
-Her membership has not changed.
-
-    >>> for m in bee.members.members:
-    ...     print(m.member_id.int, m.mailing_list.list_id, m.address.email)
-    7 bee.example.com gwen@example.com
-
 Gwen verifies her email address, and updates her membership.
 
     >>> from mailman.utilities.datetime import now

=== modified file 'src/mailman/model/docs/messagestore.rst'
--- src/mailman/model/docs/messagestore.rst	2014-09-28 00:17:05 +0000
+++ src/mailman/model/docs/messagestore.rst	2014-12-22 17:38:14 +0000
@@ -6,28 +6,20 @@
 ``X-Message-ID-Hash`` headers.  Either of these values can be combined with
 the message's ``List-Archive`` header to create a globally unique URI to the
 message object in the internet facing interface of the message store.  The
-``X-Message-ID-Hash`` is the Base32 SHA1 hash of the ``Message-ID``.
+``X-Message-ID-Hash`` is the base-32 SHA1 hash of the ``Message-ID``.
 
     >>> from mailman.interfaces.messages import IMessageStore
     >>> from zope.component import getUtility
     >>> message_store = getUtility(IMessageStore)
 
-If you try to add a message to the store which is missing the ``Message-ID``
-header, you will get an exception.
+A message with a ``Message-ID`` header can be stored.
 
     >>> msg = message_from_string("""\
     ... Subject: An important message
+    ... Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>
     ...
     ... This message is very important.
     ... """)
-    >>> message_store.add(msg)
-    Traceback (most recent call last):
-    ...
-    ValueError: Exactly one Message-ID header required
-
-However, if the message has a ``Message-ID`` header, it can be stored.
-
-    >>> msg['Message-ID'] = '<87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>'
     >>> x_message_id_hash = message_store.add(msg)
     >>> print(x_message_id_hash)
     AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35
@@ -97,15 +89,7 @@
 ================================
 
 You delete a message from the storage service by providing the ``Message-ID``
-for the message you want to delete.  If you try to delete a ``Message-ID``
-that isn't in the store, you get an exception.
-
-    >>> message_store.delete_message('nothing')
-    Traceback (most recent call last):
-    ...
-    LookupError: nothing
-
-But if you delete an existing message, it really gets deleted.
+for the message you want to delete.
 
     >>> message_id = message['message-id']
     >>> message_store.delete_message(message_id)

=== modified file 'src/mailman/model/docs/pending.rst'
--- src/mailman/model/docs/pending.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/pending.rst	2014-12-22 17:38:14 +0000
@@ -33,12 +33,12 @@
     >>> len(token)
     40
 
-There's not much you can do with tokens except to `confirm` them, which
-basically means returning the ``IPendable`` structure (as a dictionary) from
-the database that matches the token.  If the token isn't in the database, None
-is returned.
+There's not much you can do with tokens except to *confirm* them, which
+basically means returning the `IPendable` structure (as a dictionary) from the
+database that matches the token.  If the token isn't in the database, None is
+returned.
 
-    >>> pendable = pendingdb.confirm(bytes('missing'))
+    >>> pendable = pendingdb.confirm(b'missing')
     >>> print(pendable)
     None
     >>> pendable = pendingdb.confirm(token)

=== modified file 'src/mailman/model/docs/registration.rst'
--- src/mailman/model/docs/registration.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/registration.rst	2014-12-22 17:38:14 +0000
@@ -8,7 +8,7 @@
 be verified before Mailman will send them any list traffic.
 
 The ``IUserManager`` manages users, but it does so at a fairly low level.
-Specifically, it does not handle verifications, email address syntax validity
+Specifically, it does not handle verification, email address syntax validity
 checks, etc.  The ``IRegistrar`` is the interface to the object handling all
 this stuff.
 
@@ -19,7 +19,7 @@
 Here is a helper function to check the token strings.
 
     >>> def check_token(token):
-    ...     assert isinstance(token, basestring), 'Not a string'
+    ...     assert isinstance(token, str), 'Not a string'
     ...     assert len(token) == 40, 'Unexpected length: %d' % len(token)
     ...     assert token.isalnum(), 'Not alphanumeric'
     ...     print('ok')
@@ -47,31 +47,6 @@
 honestly, not as much as probably should be done.  Still, some patently bad
 addresses are rejected outright.
 
-    >>> registrar.register(mlist, '')
-    Traceback (most recent call last):
-    ...
-    InvalidEmailAddressError
-    >>> registrar.register(mlist, 'some name@example.com')
-    Traceback (most recent call last):
-    ...
-    InvalidEmailAddressError: some name@example.com
-    >>> registrar.register(mlist, '<script>@example.com')
-    Traceback (most recent call last):
-    ...
-    InvalidEmailAddressError: <script>@example.com
-    >>> registrar.register(mlist, '\xa0@example.com')
-    Traceback (most recent call last):
-    ...
-    InvalidEmailAddressError: \xa0@example.com
-    >>> registrar.register(mlist, 'noatsign')
-    Traceback (most recent call last):
-    ...
-    InvalidEmailAddressError: noatsign
-    >>> registrar.register(mlist, 'nodom@ain')
-    Traceback (most recent call last):
-    ...
-    InvalidEmailAddressError: nodom@ain
-
 
 Register an email address
 =========================
@@ -149,9 +124,9 @@
     <BLANKLINE>
     >>> dump_msgdata(items[0].msgdata)
     _parsemsg           : False
-    listname            : alpha@example.com
+    listid              : alpha.example.com
     nodecorate          : True
-    recipients          : set([u'aperson@example.com'])
+    recipients          : {'aperson@example.com'}
     reduced_list_headers: True
     version             : 3
 
@@ -312,7 +287,7 @@
 If you try to confirm a token that doesn't exist in the pending database, the
 confirm method will just return False.
 
-    >>> registrar.confirm(bytes('no token'))
+    >>> registrar.confirm(bytes(b'no token'))
     False
 
 Likewise, if you try to confirm, through the `IUserRegistrar` interface, a

=== modified file 'src/mailman/model/docs/usermanager.rst'
--- src/mailman/model/docs/usermanager.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/usermanager.rst	2014-12-22 17:38:14 +0000
@@ -44,7 +44,7 @@
 
 A user can be assigned a password.
 
-    >>> user.password = b'secret'
+    >>> user.password = 'secret'
     >>> dump_list(user.password for user in user_manager.users)
     secret
 

=== modified file 'src/mailman/model/docs/users.rst'
--- src/mailman/model/docs/users.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/model/docs/users.rst	2014-12-22 17:38:14 +0000
@@ -20,7 +20,7 @@
 Users may have a real name and a password.
 
     >>> user_1 = user_manager.create_user()
-    >>> user_1.password = b'my password'
+    >>> user_1.password = 'my password'
     >>> user_1.display_name = 'Zoe Person'
     >>> dump_list(user.display_name for user in user_manager.users)
     Zoe Person
@@ -30,7 +30,7 @@
 The password and real name can be changed at any time.
 
     >>> user_1.display_name = 'Zoe X. Person'
-    >>> user_1.password = b'another password'
+    >>> user_1.password = 'another password'
     >>> dump_list(user.display_name for user in user_manager.users)
     Zoe X. Person
     >>> dump_list(user.password for user in user_manager.users)
@@ -44,7 +44,7 @@
     ...     saved_event = event
     >>> from mailman.testing.helpers import event_subscribers
     >>> with event_subscribers(save_event):
-    ...     user_1.password = b'changed again'
+    ...     user_1.password = 'changed again'
     >>> print(saved_event)
     <PasswordChangeEvent Zoe X. Person>
 
@@ -59,20 +59,13 @@
 Basic user identification
 =========================
 
-Although rarely visible to users, every user has a unique ID in Mailman, which
-never changes.  This ID is generated randomly at the time the user is created,
-and is represented by a UUID.
+Although rarely visible to users, every user has a unique immutable ID.  This
+ID is generated randomly at the time the user is created, and is represented
+by a UUID.
 
     >>> print(user_1.user_id)
     00000000-0000-0000-0000-000000000001
 
-The user id cannot change.
-
-    >>> user_1.user_id = 'foo'
-    Traceback (most recent call last):
-    ...
-    AttributeError: can't set attribute
-
 User records also have a date on which they where created.
 
     # The test suite uses a predictable timestamp.
@@ -84,8 +77,8 @@
 ===============
 
 One of the pieces of information that a user links to is a set of email
-addresses they control, in the form of IAddress objects.  A user can control
-many addresses, but addresses may be controlled by only one user.
+addresses they control, in the form of ``IAddress`` objects.  A user can
+control many addresses, but addresses may be linked to only one user.
 
 The easiest way to link a user to an address is to just register the new
 address on a user object.
@@ -114,14 +107,6 @@
     <BLANKLINE>
     Zoe Person
 
-But don't try to link an address to more than one user.
-
-    >>> another_user = user_manager.create_user()
-    >>> another_user.link(address_1)
-    Traceback (most recent call last):
-    ...
-    AddressAlreadyLinkedError: zperson@example.net
-
 You can also ask whether a given user controls a given address.
 
     >>> user_1.controls(address_1.email)
@@ -149,17 +134,6 @@
     >>> print(user_manager.get_user('aperson@example.net'))
     None
 
-But don't try to unlink the address from a user it's not linked to.
-
-    >>> user_1.unlink(address_1)
-    Traceback (most recent call last):
-    ...
-    AddressNotLinkedError: zperson@example.net
-    >>> another_user.unlink(address_1)
-    Traceback (most recent call last):
-    ...
-    AddressNotLinkedError: zperson@example.net
-
 
 Preferred address
 =================
@@ -183,20 +157,10 @@
     >>> print(user_2.preferred_address)
     None
 
-The preferred address must be explicitly registered, however only verified
-address may be registered as preferred.
-
-    >>> anne
-    <Address: Anne Person <anne@example.com> [not verified] at ...>
-    >>> user_2.preferred_address = anne
-    Traceback (most recent call last):
-    ...
-    UnverifiedAddressError: Anne Person <anne@example.com>
-
-Once the address has been verified though, it can be set as the preferred
-address, but only if the address is either controlled by the user or
-uncontrolled.  In the latter case, setting it as the preferred address makes
-it controlled by the user.
+Once the address has been verified, it can be set as the preferred address,
+but only if the address is either controlled by the user or uncontrolled.  In
+the latter case, setting it as the preferred address makes it controlled by
+the user.
 ::
 
     >>> from mailman.utilities.datetime import now
@@ -217,17 +181,6 @@
     >>> user_2.controls(aperson.email)
     True
 
-    >>> zperson = user_manager.get_address('zperson@example.com')
-    >>> zperson.verified_on = now()
-    >>> user_2.controls(zperson.email)
-    False
-    >>> user_1.controls(zperson.email)
-    True
-    >>> user_2.preferred_address = zperson
-    Traceback (most recent call last):
-    ...
-    AddressAlreadyLinkedError: Zoe Person <zperson@example.com>
-
 A user can disavow their preferred address.
 
     >>> user_2.preferred_address
@@ -328,11 +281,11 @@
     >>> from zope.interface.verify import verifyObject
     >>> verifyObject(IRoster, memberships)
     True
-    >>> members = sorted(memberships.members)
+    >>> def sortkey(member):
+    ...     return member.address.email, member.mailing_list, member.role.value
+    >>> members = sorted(memberships.members, key=sortkey)
     >>> len(members)
     4
-    >>> def sortkey(member):
-    ...     return member.address.email, member.mailing_list, member.role.value
     >>> for member in sorted(members, key=sortkey):
     ...     print(member.address.email, member.mailing_list.list_id,
     ...           member.role)

=== modified file 'src/mailman/model/domain.py'
--- src/mailman/model/domain.py	2014-11-01 03:35:02 +0000
+++ src/mailman/model/domain.py	2014-12-22 17:38:14 +0000
@@ -26,17 +26,16 @@
     ]
 
 
-from sqlalchemy import Column, Integer, Unicode
-from urlparse import urljoin, urlparse
-from zope.event import notify
-from zope.interface import implementer
-
 from mailman.database.model import Model
 from mailman.database.transaction import dbconnection
 from mailman.interfaces.domain import (
     BadDomainSpecificationError, DomainCreatedEvent, DomainCreatingEvent,
     DomainDeletedEvent, DomainDeletingEvent, IDomain, IDomainManager)
 from mailman.model.mailinglist import MailingList
+from six.moves.urllib_parse import urljoin, urlparse
+from sqlalchemy import Column, Integer, Unicode
+from zope.event import notify
+from zope.interface import implementer
 
 
 

=== modified file 'src/mailman/model/mailinglist.py'
--- src/mailman/model/mailinglist.py	2014-11-01 16:49:15 +0000
+++ src/mailman/model/mailinglist.py	2014-12-22 17:38:14 +0000
@@ -27,16 +27,6 @@
 
 import os
 
-from sqlalchemy import (
-    Boolean, Column, DateTime, Float, ForeignKey, Integer, Interval,
-    LargeBinary, PickleType, Unicode)
-from sqlalchemy.event import listen
-from sqlalchemy.orm import relationship
-from urlparse import urljoin
-from zope.component import getUtility
-from zope.event import notify
-from zope.interface import implementer
-
 from mailman.config import config
 from mailman.database.model import Model
 from mailman.database.transaction import dbconnection
@@ -65,6 +55,15 @@
 from mailman.model.preferences import Preferences
 from mailman.utilities.filesystem import makedirs
 from mailman.utilities.string import expand
+from six.moves.urllib_parse import urljoin
+from sqlalchemy import (
+    Boolean, Column, DateTime, Float, ForeignKey, Integer, Interval,
+    LargeBinary, PickleType, Unicode)
+from sqlalchemy.event import listen
+from sqlalchemy.orm import relationship
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import implementer
 
 
 SPACE = ' '
@@ -482,7 +481,9 @@
                 Member._user == subscriber).first()
             if member:
                 raise AlreadySubscribedError(
-                    self.fqdn_listname, subscriber, role)
+                    self.fqdn_listname,
+                    subscriber.preferred_address.email,
+                    role)
         else:
             raise ValueError('subscriber must be an address or user')
         member = Member(role=role,

=== modified file 'src/mailman/model/message.py'
--- src/mailman/model/message.py	2014-09-28 00:17:05 +0000
+++ src/mailman/model/message.py	2014-12-22 17:38:14 +0000
@@ -24,7 +24,7 @@
     'Message',
     ]
 
-from sqlalchemy import Column, Integer, LargeBinary, Unicode
+from sqlalchemy import Column, Integer, Unicode
 from zope.interface import implementer
 
 from mailman.database.model import Model
@@ -42,8 +42,8 @@
     id = Column(Integer, primary_key=True)
     # This is a Messge-ID field representation, not a database row id.
     message_id = Column(Unicode)
-    message_id_hash = Column(LargeBinary)
-    path = Column(LargeBinary)
+    message_id_hash = Column(Unicode)
+    path = Column(Unicode)
 
     @dbconnection
     def __init__(self, store, message_id, message_id_hash, path):

=== modified file 'src/mailman/model/messagestore.py'
--- src/mailman/model/messagestore.py	2014-12-09 01:38:26 +0000
+++ src/mailman/model/messagestore.py	2014-12-22 17:38:14 +0000
@@ -28,16 +28,15 @@
 import os
 import errno
 import base64
+import pickle
 import hashlib
-import cPickle as pickle
-
-from zope.interface import implementer
 
 from mailman.config import config
 from mailman.database.transaction import dbconnection
 from mailman.interfaces.messages import IMessageStore
 from mailman.model.message import Message
 from mailman.utilities.filesystem import makedirs
+from zope.interface import implementer
 
 
 # It could be very bad if you have already stored files and you change this
@@ -68,8 +67,8 @@
             raise ValueError(
                 'Message ID already exists in message store: {0}'.format(
                     message_id))
-        shaobj = hashlib.sha1(message_id)
-        hash32 = base64.b32encode(shaobj.digest())
+        shaobj = hashlib.sha1(message_id.encode('utf-8'))
+        hash32 = base64.b32encode(shaobj.digest()).decode('utf-8')
         del message['X-Message-ID-Hash']
         message['X-Message-ID-Hash'] = hash32
         # Calculate the path on disk where we're going to store this message
@@ -94,7 +93,7 @@
         # them and try again.
         while True:
             try:
-                with open(path, 'w') as fp:
+                with open(path, 'wb') as fp:
                     # -1 says to use the highest protocol available.
                     pickle.dump(message, fp, -1)
                     break
@@ -106,7 +105,7 @@
 
     def _get_message(self, row):
         path = os.path.join(config.MESSAGES_DIR, row.path)
-        with open(path) as fp:
+        with open(path, 'rb') as fp:
             return pickle.load(fp)
 
     @dbconnection
@@ -118,11 +117,6 @@
 
     @dbconnection
     def get_message_by_hash(self, store, message_id_hash):
-        # It's possible the hash came from a message header, in which case it
-        # will be a Unicode.  However when coming from source code, it may be
-        # bytes object.  Coerce to the latter if necessary; it must be ASCII.
-        if not isinstance(message_id_hash, bytes):
-            message_id_hash = message_id_hash.encode('ascii')
         row = store.query(Message).filter_by(
             message_id_hash=message_id_hash).first()
         if row is None:

=== modified file 'src/mailman/model/pending.py'
--- src/mailman/model/pending.py	2014-10-10 04:59:43 +0000
+++ src/mailman/model/pending.py	2014-12-22 17:38:14 +0000
@@ -26,13 +26,13 @@
     ]
 
 
+import json
 import time
 import random
 import hashlib
 
 from lazr.config import as_timedelta
-from sqlalchemy import (
-    Column, DateTime, ForeignKey, Integer, LargeBinary, Unicode)
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, Unicode
 from sqlalchemy.orm import relationship
 from zope.interface import implementer
 from zope.interface.verify import verifyObject
@@ -71,7 +71,7 @@
     __tablename__ = 'pended'
 
     id = Column(Integer, primary_key=True)
-    token = Column(LargeBinary)
+    token = Column(Unicode)
     expiration_date = Column(DateTime)
     key_values = relationship('PendedKeyValue')
 
@@ -108,33 +108,26 @@
             right_now = time.time()
             x = random.random() + right_now % 1.0 + time.clock() % 1.0
             # Use sha1 because it produces shorter strings.
-            token = hashlib.sha1(repr(x)).hexdigest()
+            token = hashlib.sha1(repr(x).encode('utf-8')).hexdigest()
             # In practice, we'll never get a duplicate, but we'll be anal
             # about checking anyway.
             if store.query(Pended).filter_by(token=token).count() == 0:
                 break
         else:
-            raise AssertionError('Could not find a valid pendings token')
+            raise RuntimeError('Could not find a valid pendings token')
         # Create the record, and then the individual key/value pairs.
         pending = Pended(
             token=token,
             expiration_date=now() + lifetime)
         for key, value in pendable.items():
+            # Both keys and values must be strings.
             if isinstance(key, bytes):
                 key = key.decode('utf-8')
             if isinstance(value, bytes):
-                value = value.decode('utf-8')
-            elif type(value) is int:
-                value = '__builtin__.int\1%s' % value
-            elif type(value) is float:
-                value = '__builtin__.float\1%s' % value
-            elif type(value) is bool:
-                value = '__builtin__.bool\1%s' % value
-            elif type(value) is list:
-                # We expect this to be a list of strings.
-                value = ('mailman.model.pending.unpack_list\1' +
-                         '\2'.join(value))
-            keyval = PendedKeyValue(key=key, value=value)
+                # Make sure we can turn this back into a bytes.
+                value  = dict(__encoding__='utf-8',
+                              value=value.decode('utf-8'))
+            keyval = PendedKeyValue(key=key, value=json.dumps(value))
             pending.key_values.append(keyval)
         store.add(pending)
         return token
@@ -155,11 +148,10 @@
         entries = store.query(PendedKeyValue).filter(
             PendedKeyValue.pended_id == pending.id)
         for keyvalue in entries:
-            if keyvalue.value is not None and '\1' in keyvalue.value:
-                type_name, value = keyvalue.value.split('\1', 1)
-                pendable[keyvalue.key] = call_name(type_name, value)
-            else:
-                pendable[keyvalue.key] = keyvalue.value
+            value = json.loads(keyvalue.value)
+            if isinstance(value, dict) and '__encoding__' in value:
+                value = value['value'].encode(value['__encoding__'])
+            pendable[keyvalue.key] = value
             if expunge:
                 store.delete(keyvalue)
         if expunge:
@@ -178,8 +170,3 @@
                 for keyvalue in q:
                     store.delete(keyvalue)
                 store.delete(pending)
-
-
-
-def unpack_list(value):
-    return value.split('\2')

=== modified file 'src/mailman/model/requests.py'
--- src/mailman/model/requests.py	2014-10-10 04:59:43 +0000
+++ src/mailman/model/requests.py	2014-12-22 17:38:14 +0000
@@ -24,18 +24,19 @@
     ]
 
 
-from cPickle import dumps, loads
+import six
+
 from datetime import timedelta
-from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, Unicode
-from sqlalchemy.orm import relationship
-from zope.component import getUtility
-from zope.interface import implementer
-
 from mailman.database.model import Model
 from mailman.database.transaction import dbconnection
 from mailman.database.types import Enum
 from mailman.interfaces.pending import IPendable, IPendings
 from mailman.interfaces.requests import IListRequests, RequestType
+from six.moves.cPickle import dumps, loads
+from sqlalchemy import Column, ForeignKey, Integer, Unicode
+from sqlalchemy.orm import relationship
+from zope.component import getUtility
+from zope.interface import implementer
 
 
 
@@ -50,8 +51,8 @@
         # such a way that it will be properly reconstituted when unpended.
         clean_mapping = {}
         for key, value in mapping.items():
-            assert isinstance(key, basestring)
-            if not isinstance(value, unicode):
+            assert isinstance(key, six.string_types)
+            if not isinstance(value, six.text_type):
                 key = '_pck_' + key
                 value = dumps(value).decode('raw-unicode-escape')
             clean_mapping[key] = value
@@ -154,7 +155,7 @@
     id = Column(Integer, primary_key=True)
     key = Column(Unicode)
     request_type = Column(Enum(RequestType))
-    data_hash = Column(LargeBinary)
+    data_hash = Column(Unicode)
 
     mailing_list_id = Column(Integer, ForeignKey('mailinglist.id'), index=True)
     mailing_list = relationship('MailingList')

=== modified file 'src/mailman/model/tests/test_address.py'
--- src/mailman/model/tests/test_address.py	2014-04-15 03:00:41 +0000
+++ src/mailman/model/tests/test_address.py	2014-12-22 17:38:14 +0000
@@ -28,8 +28,11 @@
 import unittest
 
 from mailman.email.validate import InvalidEmailAddressError
+from mailman.interfaces.address import ExistingAddressError
+from mailman.interfaces.usermanager import IUserManager
 from mailman.model.address import Address
 from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
 
 
 
@@ -38,6 +41,25 @@
 
     layer = ConfigLayer
 
+    def setUp(self):
+        self._usermgr = getUtility(IUserManager)
+        self._address = self._usermgr.create_address('FPERSON@example.com')
+
     def test_invalid_email_string_raises_exception(self):
         with self.assertRaises(InvalidEmailAddressError):
             Address('not_a_valid_email_string', '')
+
+    def test_local_part_differs_only_by_case(self):
+        with self.assertRaises(ExistingAddressError) as cm:
+            self._usermgr.create_address('fperson@example.com')
+        self.assertEqual(cm.exception.address, 'FPERSON@example.com')
+
+    def test_domain_part_differs_only_by_case(self):
+        with self.assertRaises(ExistingAddressError) as cm:
+            self._usermgr.create_address('fperson@EXAMPLE.COM')
+        self.assertEqual(cm.exception.address, 'FPERSON@example.com')
+
+    def test_mixed_case_exact_match(self):
+        with self.assertRaises(ExistingAddressError) as cm:
+            self._usermgr.create_address('FPERSON@example.com')
+        self.assertEqual(cm.exception.address, 'FPERSON@example.com')

=== modified file 'src/mailman/model/tests/test_domain.py'
--- src/mailman/model/tests/test_domain.py	2014-04-28 15:23:35 +0000
+++ src/mailman/model/tests/test_domain.py	2014-12-22 17:38:14 +0000
@@ -45,6 +45,7 @@
 
     def setUp(self):
         self._events = []
+        self._manager = getUtility(IDomainManager)
 
     def _record_event(self, event):
         self._events.append(event)
@@ -53,7 +54,7 @@
         # Test that creating a domain in the domain manager propagates the
         # expected events.
         with event_subscribers(self._record_event):
-            domain = getUtility(IDomainManager).add('example.org')
+            domain = self._manager.add('example.org')
         self.assertEqual(len(self._events), 2)
         self.assertTrue(isinstance(self._events[0], DomainCreatingEvent))
         self.assertEqual(self._events[0].mail_host, 'example.org')
@@ -63,15 +64,24 @@
     def test_delete_domain_event(self):
         # Test that deleting a domain in the domain manager propagates the
         # expected event.
-        domain = getUtility(IDomainManager).add('example.org')
+        domain = self._manager.add('example.org')
         with event_subscribers(self._record_event):
-            getUtility(IDomainManager).remove('example.org')
+            self._manager.remove('example.org')
         self.assertEqual(len(self._events), 2)
         self.assertTrue(isinstance(self._events[0], DomainDeletingEvent))
         self.assertEqual(self._events[0].domain, domain)
         self.assertTrue(isinstance(self._events[1], DomainDeletedEvent))
         self.assertEqual(self._events[1].mail_host, 'example.org')
 
+    def test_lookup_missing_domain(self):
+        # Like dictionaries, getitem syntax raises KeyError on missing domain.
+        with self.assertRaises(KeyError):
+            self._manager['doesnotexist.com']
+
+    def test_delete_missing_domain(self):
+        # Trying to delete a missing domain gives you a KeyError.
+        self.assertRaises(KeyError, self._manager.remove, 'doesnotexist.com')
+
 
 
 class TestDomainLifecycleEvents(unittest.TestCase):

=== modified file 'src/mailman/model/tests/test_listmanager.py'
--- src/mailman/model/tests/test_listmanager.py	2014-09-22 18:47:02 +0000
+++ src/mailman/model/tests/test_listmanager.py	2014-12-22 17:38:14 +0000
@@ -29,14 +29,13 @@
 
 import unittest
 
-from zope.component import getUtility
-
 from mailman.app.lifecycle import create_list
 from mailman.app.moderator import hold_message
 from mailman.config import config
+from mailman.interfaces.address import InvalidEmailAddressError
 from mailman.interfaces.listmanager import (
-    IListManager, ListCreatedEvent, ListCreatingEvent, ListDeletedEvent,
-    ListDeletingEvent)
+    IListManager, ListAlreadyExistsError, ListCreatedEvent, ListCreatingEvent,
+    ListDeletedEvent, ListDeletingEvent)
 from mailman.interfaces.messages import IMessageStore
 from mailman.interfaces.requests import IListRequests
 from mailman.interfaces.subscriptions import ISubscriptionService
@@ -45,6 +44,7 @@
 from mailman.testing.helpers import (
     event_subscribers, specialized_message_from_string)
 from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
 
 
 
@@ -157,11 +157,23 @@
 class TestListCreation(unittest.TestCase):
     layer = ConfigLayer
 
+    def setUp(self):
+        self._manager = getUtility(IListManager)
+
     def test_create_list_case_folding(self):
         # LP: #1117176 describes a problem where list names created in upper
         # case are not actually usable by the LMTP server.
-        manager = getUtility(IListManager)
-        manager.create('my-LIST@example.com')
-        self.assertIsNone(manager.get('my-LIST@example.com'))
-        mlist = manager.get('my-list@example.com')
+        self._manager.create('my-LIST@example.com')
+        self.assertIsNone(self._manager.get('my-LIST@example.com'))
+        mlist = self._manager.get('my-list@example.com')
         self.assertEqual(mlist.list_id, 'my-list.example.com')
+
+    def test_cannot_create_a_list_twice(self):
+        self._manager.create('ant@example.com')
+        self.assertRaises(ListAlreadyExistsError,
+                          self._manager.create, 'ant@example.com')
+
+    def test_list_name_must_be_fully_qualified(self):
+        with self.assertRaises(InvalidEmailAddressError) as cm:
+            self._manager.create('foo')
+        self.assertEqual(cm.exception.email, 'foo')

=== modified file 'src/mailman/model/tests/test_mailinglist.py'
--- src/mailman/model/tests/test_mailinglist.py	2014-01-01 14:59:42 +0000
+++ src/mailman/model/tests/test_mailinglist.py	2014-12-22 17:38:14 +0000
@@ -21,8 +21,9 @@
 
 __metaclass__ = type
 __all__ = [
+    'TestDisabledListArchiver',
     'TestListArchiver',
-    'TestDisabledListArchiver',
+    'TestMailingList',
     ]
 
 
@@ -31,8 +32,47 @@
 from mailman.app.lifecycle import create_list
 from mailman.config import config
 from mailman.interfaces.mailinglist import IListArchiverSet
+from mailman.interfaces.member import (
+    AlreadySubscribedError, MemberRole, MissingPreferredAddressError)
+from mailman.interfaces.usermanager import IUserManager
 from mailman.testing.helpers import configuration
 from mailman.testing.layers import ConfigLayer
+from mailman.utilities.datetime import now
+from zope.component import getUtility
+
+
+
+class TestMailingList(unittest.TestCase):
+    layer = ConfigLayer
+
+    def setUp(self):
+        self._mlist = create_list('ant@example.com')
+
+    def test_no_duplicate_subscriptions(self):
+        # A user is not allowed to subscribe more than once to the mailing
+        # list with the same role.
+        anne = getUtility(IUserManager).create_user('anne@example.com')
+        # Give the user a preferred address.
+        preferred = list(anne.addresses)[0]
+        preferred.verified_on = now()
+        anne.preferred_address = preferred
+        # Subscribe Anne to the mailing list as a regular member.
+        member = self._mlist.subscribe(anne)
+        self.assertEqual(member.address, preferred)
+        self.assertEqual(member.role, MemberRole.member)
+        # A second subscription with the same role will fail.
+        with self.assertRaises(AlreadySubscribedError) as cm:
+            self._mlist.subscribe(anne)
+        self.assertEqual(cm.exception.fqdn_listname, 'ant@example.com')
+        self.assertEqual(cm.exception.email, 'anne@example.com')
+        self.assertEqual(cm.exception.role, MemberRole.member)
+
+    def test_subscribing_user_must_have_preferred_address(self):
+        # A user object cannot be subscribed to a mailing list without a
+        # preferred address.
+        anne = getUtility(IUserManager).create_user('anne@example.com')
+        self.assertRaises(MissingPreferredAddressError,
+                          self._mlist.subscribe, anne)
 
 
 

=== added file 'src/mailman/model/tests/test_messagestore.py'
--- src/mailman/model/tests/test_messagestore.py	1970-01-01 00:00:00 +0000
+++ src/mailman/model/tests/test_messagestore.py	2014-12-22 17:38:14 +0000
@@ -0,0 +1,74 @@
+# 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 message store."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'TestMessageStore',
+    ]
+
+
+import unittest
+
+from mailman.interfaces.messages import IMessageStore
+from mailman.testing.helpers import (
+    specialized_message_from_string as mfs)
+from mailman.testing.layers import ConfigLayer
+from mailman.utilities.email import add_message_hash
+from zope.component import getUtility
+
+
+
+class TestMessageStore(unittest.TestCase):
+    layer = ConfigLayer
+
+    def setUp(self):
+        self._store = getUtility(IMessageStore)
+
+    def test_message_id_required(self):
+        # The Message-ID header is required in order to add it to the store.
+        message = mfs("""\
+Subject: An important message
+
+This message is very important.
+""")
+        self.assertRaises(ValueError, self._store.add, message)
+
+    def test_get_message_by_hash(self):
+        # Messages have an X-Message-ID-Hash header, the value of which can be
+        # used to look the message up in the message store.
+        message = mfs("""\
+Subject: An important message
+Message-ID: <ant>
+
+This message is very important.
+""")
+        add_message_hash(message)
+        self._store.add(message)
+        self.assertEqual(message['x-message-id-hash'],
+                         'V3YEHAFKE2WVJNK63Z7RFP4JMHISI2RG')
+        found = self._store.get_message_by_hash(
+            'V3YEHAFKE2WVJNK63Z7RFP4JMHISI2RG')
+        self.assertEqual(found['message-id'], '<ant>')
+        self.assertEqual(found['x-message-id-hash'],
+                         'V3YEHAFKE2WVJNK63Z7RFP4JMHISI2RG')
+
+    def test_cannot_delete_missing_message(self):
+        self.assertRaises(LookupError, self._store.delete_message, 'missing')

=== added file 'src/mailman/model/tests/test_registrar.py'
--- src/mailman/model/tests/test_registrar.py	1970-01-01 00:00:00 +0000
+++ src/mailman/model/tests/test_registrar.py	2014-12-22 17:38:14 +0000
@@ -0,0 +1,64 @@
+# 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 `IRegistrar`."""
+
+__all__ = [
+    'TestRegistrar',
+    ]
+
+
+import unittest
+
+from functools import partial
+from mailman.app.lifecycle import create_list
+from mailman.interfaces.address import InvalidEmailAddressError
+from mailman.interfaces.registrar import IRegistrar
+from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
+
+
+
+class TestRegistrar(unittest.TestCase):
+    layer = ConfigLayer
+
+    def setUp(self):
+        mlist = create_list('test@example.com')
+        self._register = partial(getUtility(IRegistrar).register, mlist)
+
+    def test_invalid_empty_string(self):
+        self.assertRaises(InvalidEmailAddressError, self._register, '')
+
+    def test_invalid_space_in_name(self):
+        self.assertRaises(InvalidEmailAddressError, self._register,
+                          'some name@example.com')
+
+    def test_invalid_funky_characters(self):
+        self.assertRaises(InvalidEmailAddressError, self._register,
+                          '<script>@example.com')
+
+    def test_invalid_nonascii(self):
+        self.assertRaises(InvalidEmailAddressError, self._register,
+                          '\xa0@example.com')
+
+    def test_invalid_no_at_sign(self):
+        self.assertRaises(InvalidEmailAddressError, self._register,
+                          'noatsign')
+
+    def test_invalid_no_domain(self):
+        self.assertRaises(InvalidEmailAddressError, self._register,
+                          'nodom@ain')

=== modified file 'src/mailman/model/tests/test_user.py'
--- src/mailman/model/tests/test_user.py	2014-04-28 15:23:35 +0000
+++ src/mailman/model/tests/test_user.py	2014-12-22 17:38:14 +0000
@@ -27,12 +27,14 @@
 
 import unittest
 
-from zope.component import getUtility
-
 from mailman.app.lifecycle import create_list
+from mailman.interfaces.address import (
+    AddressAlreadyLinkedError, AddressNotLinkedError)
+from mailman.interfaces.user import UnverifiedAddressError
 from mailman.interfaces.usermanager import IUserManager
 from mailman.testing.layers import ConfigLayer
 from mailman.utilities.datetime import now
+from zope.component import getUtility
 
 
 
@@ -74,3 +76,38 @@
         self.assertEqual(len(emails), 2)
         self.assertEqual(emails,
                          set(['anne@example.com', 'aperson@example.com']))
+
+    def test_uid_is_immutable(self):
+        with self.assertRaises(AttributeError):
+            self._anne.user_id = 'foo'
+
+    def test_addresses_may_only_be_linked_to_one_user(self):
+        user = getUtility(IUserManager).create_user()
+        # Anne's preferred address is already linked to her.
+        with self.assertRaises(AddressAlreadyLinkedError) as cm:
+            user.link(self._anne.preferred_address)
+        self.assertEqual(cm.exception.address, self._anne.preferred_address)
+
+    def test_unlink_from_address_not_linked_to(self):
+        # You cannot unlink an address from a user if that address is not
+        # already linked to the user.
+        user = getUtility(IUserManager).create_user()
+        with self.assertRaises(AddressNotLinkedError) as cm:
+            user.unlink(self._anne.preferred_address)
+        self.assertEqual(cm.exception.address, self._anne.preferred_address)
+
+    def test_unlink_address_which_is_not_linked(self):
+        # You cannot unlink an address which is not linked to any user.
+        address = getUtility(IUserManager).create_address('bart@example.com')
+        user = getUtility(IUserManager).create_user()
+        with self.assertRaises(AddressNotLinkedError) as cm:
+            user.unlink(address)
+        self.assertEqual(cm.exception.address, address)
+
+    def test_set_unverified_preferred_address(self):
+        # A user's preferred address cannot be set to an unverified address.
+        new_preferred = getUtility(IUserManager).create_address(
+            'anne.person@example.com')
+        with self.assertRaises(UnverifiedAddressError) as cm:
+            self._anne.preferred_address = new_preferred
+        self.assertEqual(cm.exception.address, new_preferred)

=== modified file 'src/mailman/model/user.py'
--- src/mailman/model/user.py	2014-10-13 19:24:24 +0000
+++ src/mailman/model/user.py	2014-12-22 17:38:14 +0000
@@ -24,8 +24,8 @@
     'User',
     ]
 
-from sqlalchemy import (
-    Column, DateTime, ForeignKey, Integer, LargeBinary, Unicode)
+
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, Unicode
 from sqlalchemy.orm import relationship, backref
 from zope.event import notify
 from zope.interface import implementer
@@ -56,7 +56,7 @@
 
     id = Column(Integer, primary_key=True)
     display_name = Column(Unicode)
-    _password = Column('password', LargeBinary)
+    _password = Column('password', Unicode)
     _user_id = Column(UUID, index=True)
     _created_on = Column(DateTime)
 
@@ -122,7 +122,7 @@
 
     def unlink(self, address):
         """See `IUser`."""
-        if address.user is None:
+        if address.user is None or address.user is not self:
             raise AddressNotLinkedError(address)
         address.user = None
 

=== modified file 'src/mailman/mta/docs/authentication.rst'
--- src/mailman/mta/docs/authentication.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/mta/docs/authentication.rst	2014-12-22 17:38:14 +0000
@@ -60,7 +60,7 @@
     >>> response = bulk.deliver(
     ...     mlist, msg, dict(recipients=['bperson@example.com']))
     >>> dump_msgdata(response)
-    bperson@example.com: (571, 'Bad authentication')
+    bperson@example.com: (571, b'Bad authentication')
 
     >>> config.pop('auth')
 

=== modified file 'src/mailman/mta/docs/bulk.rst'
--- src/mailman/mta/docs/bulk.rst	2014-11-08 00:31:21 +0000
+++ src/mailman/mta/docs/bulk.rst	2014-12-22 17:38:14 +0000
@@ -332,7 +332,8 @@
 
     >>> failures = bulk.deliver(mlist, msg, msgdata)
     >>> for address in sorted(failures):
-    ...     print(address, failures[address][0], failures[address][1])
+    ...     print(address, failures[address][0],
+    ...                    failures[address][1].decode('ascii'))
     aperson@example.org 500 Error: SMTPRecipientsRefused
     bperson@example.org 500 Error: SMTPRecipientsRefused
     cperson@example.org 500 Error: SMTPRecipientsRefused
@@ -350,7 +351,8 @@
 
     >>> failures = bulk.deliver(mlist, msg, msgdata)
     >>> for address in sorted(failures):
-    ...     print(address, failures[address][0], failures[address][1])
+    ...     print(address, failures[address][0],
+    ...                    failures[address][1].decode('ascii'))
     aperson@example.org 450 Error: SMTPResponseException
     bperson@example.org 450 Error: SMTPResponseException
     cperson@example.org 450 Error: SMTPResponseException
@@ -361,7 +363,8 @@
 
     >>> failures = bulk.deliver(mlist, msg, msgdata)
     >>> for address in sorted(failures):
-    ...     print(address, failures[address][0], failures[address][1])
+    ...     print(address, failures[address][0],
+    ...                    failures[address][1].decode('ascii'))
     aperson@example.org 500 Error: SMTPResponseException
     bperson@example.org 500 Error: SMTPResponseException
     cperson@example.org 500 Error: SMTPResponseException

=== modified file 'src/mailman/mta/docs/connection.rst'
--- src/mailman/mta/docs/connection.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/mta/docs/connection.rst	2014-12-22 17:38:14 +0000
@@ -75,30 +75,6 @@
     >>> reset()
     >>> config.pop('auth')
 
-However, a bad user name or password generates an error.
-
-    >>> config.push('auth', """
-    ... [mta]
-    ... smtp_user: baduser
-    ... smtp_pass: badpass
-    ... """)
-
-    >>> connection = Connection(
-    ...     config.mta.smtp_host, int(config.mta.smtp_port), 0,
-    ...     config.mta.smtp_user, config.mta.smtp_pass)
-    >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\
-    ... From: anne@example.com
-    ... To: bart@example.com
-    ... Subject: aardvarks
-    ...
-    ... """)
-    Traceback (most recent call last):
-    ...
-    SMTPAuthenticationError: (571, 'Bad authentication')
-
-    >>> reset()
-    >>> config.pop('auth')
-
 
 Sessions per connection
 =======================

=== added file 'src/mailman/mta/tests/test_connection.py'
--- src/mailman/mta/tests/test_connection.py	1970-01-01 00:00:00 +0000
+++ src/mailman/mta/tests/test_connection.py	2014-12-22 17:38:14 +0000
@@ -0,0 +1,54 @@
+# 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 MTA connections."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'TestConnection',
+    ]
+
+
+import unittest
+
+from mailman.config import config
+from mailman.mta.connection import Connection
+from mailman.testing.layers import SMTPLayer
+from smtplib import SMTPAuthenticationError
+
+
+
+class TestConnection(unittest.TestCase):
+    layer = SMTPLayer
+
+    def test_authentication_error(self):
+        # Logging in to the MTA with a bad user name and password produces a
+        # 571 Bad Authentication error.
+        with self.assertRaises(SMTPAuthenticationError) as cm:
+            connection = Connection(
+                config.mta.smtp_host, int(config.mta.smtp_port), 0,
+                'baduser', 'badpass')
+            connection.sendmail('anne@example.com', ['bart@example.com'], """\
+From: anne@example.com
+To: bart@example.com
+Subject: aardvarks
+
+""")
+        self.assertEqual(cm.exception.smtp_code, 571)
+        self.assertEqual(cm.exception.smtp_error, b'Bad authentication')

=== modified file 'src/mailman/options.py'
--- src/mailman/options.py	2014-04-28 15:23:35 +0000
+++ src/mailman/options.py	2014-12-22 17:38:14 +0000
@@ -42,7 +42,7 @@
 
 def check_unicode(option, opt, value):
     """Check that the value is a unicode string."""
-    if isinstance(value, unicode):
+    if not isinstance(value, bytes):
         return value
     try:
         return value.decode(sys.getdefaultencoding())

=== modified file 'src/mailman/rest/addresses.py'
--- src/mailman/rest/addresses.py	2014-12-08 22:29:19 +0000
+++ src/mailman/rest/addresses.py	2014-12-22 17:38:14 +0000
@@ -27,6 +27,8 @@
     ]
 
 
+import six
+
 from operator import attrgetter
 from zope.component import getUtility
 
@@ -197,8 +199,8 @@
             not_found(response)
             return
         user_manager = getUtility(IUserManager)
-        validator = Validator(email=unicode,
-                              display_name=unicode,
+        validator = Validator(email=six.text_type,
+                              display_name=six.text_type,
                               _optional=('display_name',))
         try:
             address = user_manager.create_address(**validator(request))

=== modified file 'src/mailman/rest/configuration.py'
--- src/mailman/rest/configuration.py	2014-11-15 17:01:30 +0000
+++ src/mailman/rest/configuration.py	2014-12-22 17:38:14 +0000
@@ -25,6 +25,8 @@
     ]
 
 
+import six
+
 from lazr.config import as_boolean, as_timedelta
 from mailman.config import config
 from mailman.core.errors import (
@@ -61,7 +63,7 @@
         alias_set = IAcceptableAliasSet(mlist)
         alias_set.clear()
         for alias in value:
-            alias_set.add(unicode(alias))
+            alias_set.add(alias)
 
 
 
@@ -71,13 +73,16 @@
 def pipeline_validator(pipeline_name):
     """Convert the pipeline name to a string, but only if it's known."""
     if pipeline_name in config.pipelines:
-        return unicode(pipeline_name)
+        return pipeline_name
     raise ValueError('Unknown pipeline: {}'.format(pipeline_name))
 
 
-def list_of_unicode(values):
+def list_of_str(values):
     """Turn a list of things into a list of unicodes."""
-    return [unicode(value) for value in values]
+    for value in values:
+        if not isinstance(value, str):
+            raise ValueError('Expected str, got {!r}'.format(value))
+    return values
 
 
 
@@ -96,7 +101,7 @@
 # (e.g. datetimes, timedeltas, enums).
 
 ATTRIBUTES = dict(
-    acceptable_aliases=AcceptableAliases(list_of_unicode),
+    acceptable_aliases=AcceptableAliases(list_of_str),
     admin_immed_notify=GetterSetter(as_boolean),
     admin_notify_mchanges=GetterSetter(as_boolean),
     administrivia=GetterSetter(as_boolean),
@@ -106,9 +111,9 @@
     autorespond_postings=GetterSetter(enum_validator(ResponseAction)),
     autorespond_requests=GetterSetter(enum_validator(ResponseAction)),
     autoresponse_grace_period=GetterSetter(as_timedelta),
-    autoresponse_owner_text=GetterSetter(unicode),
-    autoresponse_postings_text=GetterSetter(unicode),
-    autoresponse_request_text=GetterSetter(unicode),
+    autoresponse_owner_text=GetterSetter(six.text_type),
+    autoresponse_postings_text=GetterSetter(six.text_type),
+    autoresponse_request_text=GetterSetter(six.text_type),
     archive_policy=GetterSetter(enum_validator(ArchivePolicy)),
     bounces_address=GetterSetter(None),
     collapse_alternatives=GetterSetter(as_boolean),
@@ -116,7 +121,7 @@
     created_at=GetterSetter(None),
     default_member_action=GetterSetter(enum_validator(Action)),
     default_nonmember_action=GetterSetter(enum_validator(Action)),
-    description=GetterSetter(unicode),
+    description=GetterSetter(six.text_type),
     digest_last_sent_at=GetterSetter(None),
     digest_size_threshold=GetterSetter(float),
     filter_content=GetterSetter(as_boolean),
@@ -135,21 +140,21 @@
     post_id=GetterSetter(None),
     posting_address=GetterSetter(None),
     posting_pipeline=GetterSetter(pipeline_validator),
-    display_name=GetterSetter(unicode),
+    display_name=GetterSetter(six.text_type),
     reply_goes_to_list=GetterSetter(enum_validator(ReplyToMunging)),
-    reply_to_address=GetterSetter(unicode),
+    reply_to_address=GetterSetter(six.text_type),
     request_address=GetterSetter(None),
     scheme=GetterSetter(None),
     send_welcome_message=GetterSetter(as_boolean),
-    subject_prefix=GetterSetter(unicode),
+    subject_prefix=GetterSetter(six.text_type),
     volume=GetterSetter(None),
     web_host=GetterSetter(None),
-    welcome_message_uri=GetterSetter(unicode),
+    welcome_message_uri=GetterSetter(six.text_type),
     )
 
 
 VALIDATORS = ATTRIBUTES.copy()
-for attribute, gettersetter in VALIDATORS.items():
+for attribute, gettersetter in list(VALIDATORS.items()):
     if gettersetter.decoder is None:
         del VALIDATORS[attribute]
 

=== modified file 'src/mailman/rest/docs/addresses.rst'
--- src/mailman/rest/docs/addresses.rst	2014-12-10 01:03:43 +0000
+++ src/mailman/rest/docs/addresses.rst	2014-12-22 17:38:14 +0000
@@ -64,13 +64,6 @@
     registered_on: 2005-08-01T07:49:23
     self_link: http://localhost:9001/3.0/addresses/bart.person@example.com
 
-A non-existent email address can't be retrieved.
-
-    >>> dump_json('http://localhost:9001/3.0/addresses/nobody@example.com')
-    Traceback (most recent call last):
-    ...
-    HTTPError: HTTP Error 404: 404 Not Found
-
 When an address has a real name associated with it, this is also available in
 the REST API.
 

=== modified file 'src/mailman/rest/docs/basic.rst'
--- src/mailman/rest/docs/basic.rst	2014-11-15 17:01:30 +0000
+++ src/mailman/rest/docs/basic.rst	2014-12-22 17:38:14 +0000
@@ -24,13 +24,10 @@
 When the `Authorization` header contains the proper credentials, the request
 succeeds.
 
-    >>> from base64 import b64encode
     >>> from httplib2 import Http
-    >>> auth = b64encode('{0}:{1}'.format(config.webservice.admin_user,
-    ...                                   config.webservice.admin_pass))
     >>> headers = {
     ...     'Content-Type': 'application/x-www-form-urlencode',
-    ...     'Authorization': 'Basic ' + auth,
+    ...     'Authorization': 'Basic cmVzdGFkbWluOnJlc3RwYXNz',
     ...     }
     >>> url = 'http://localhost:9001/3.0/system'
     >>> response, content = Http().request(url, 'GET', None, headers)

=== modified file 'src/mailman/rest/docs/domains.rst'
--- src/mailman/rest/docs/domains.rst	2014-08-13 14:30:13 +0000
+++ src/mailman/rest/docs/domains.rst	2014-12-22 17:38:14 +0000
@@ -228,13 +228,5 @@
     server: ...
     status: 204
 
-It is an error to delete a domain twice.
-
-    >>> dump_json('http://localhost:9001/3.0/domains/lists.example.com',
-    ...           method='DELETE')
-    Traceback (most recent call last):
-    ...
-    HTTPError: HTTP Error 404: 404 Not Found
-
 
 .. _Domains: ../../model/docs/domains.html

=== modified file 'src/mailman/rest/docs/helpers.rst'
--- src/mailman/rest/docs/helpers.rst	2014-11-08 15:27:56 +0000
+++ src/mailman/rest/docs/helpers.rst	2014-12-22 17:38:14 +0000
@@ -45,7 +45,7 @@
     >>> resource = dict(geddy='bass', alex='guitar', neil='drums')
     >>> json_data = etag(resource)
     >>> print(resource['http_etag'])
-    "96e036d66248cab746b7d97047e08896fcfb2493"
+    "6929ecfbda2282980a4818fb75f82e812077f77a"
 
 For convenience, the etag function also returns the JSON representation of the
 dictionary after tagging, since that's almost always what you want.
@@ -58,7 +58,7 @@
     >>> dump_msgdata(data)
     alex     : guitar
     geddy    : bass
-    http_etag: "96e036d66248cab746b7d97047e08896fcfb2493"
+    http_etag: "6929ecfbda2282980a4818fb75f82e812077f77a"
     neil     : drums
 
 
@@ -69,8 +69,9 @@
 converting their values.
 ::
 
+    >>> import six
     >>> from mailman.rest.validator import Validator
-    >>> validator = Validator(one=int, two=unicode, three=bool)
+    >>> validator = Validator(one=int, two=six.text_type, three=bool)
 
     >>> class FakeRequest:
     ...     params = None
@@ -81,7 +82,7 @@
     >>> def print_request(one, two, three):
     ...     print(repr(one), repr(two), repr(three))
     >>> print_request(**validator(FakeRequest))
-    1 u'two' True
+    1 'two' True
 
 On invalid input, an exception is raised.
 
@@ -119,7 +120,7 @@
 However, if optional keys are missing, it's okay.
 ::
 
-    >>> validator = Validator(one=int, two=unicode, three=bool,
+    >>> validator = Validator(one=int, two=six.text_type, three=bool,
     ...                       four=int, five=int,
     ...                       _optional=('four', 'five'))
 
@@ -128,15 +129,15 @@
     >>> def print_request(one, two, three, four=None, five=None):
     ...     print(repr(one), repr(two), repr(three), repr(four), repr(five))
     >>> print_request(**validator(FakeRequest))
-    1 u'two' True 4 5
+    1 'two' True 4 5
 
     >>> del FakeRequest.params['four']
     >>> print_request(**validator(FakeRequest))
-    1 u'two' True None 5
+    1 'two' True None 5
 
     >>> del FakeRequest.params['five']
     >>> print_request(**validator(FakeRequest))
-    1 u'two' True None None
+    1 'two' True None None
 
 But if the optional values are present, they must of course also be valid.
 

=== modified file 'src/mailman/rest/docs/membership.rst'
--- src/mailman/rest/docs/membership.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/rest/docs/membership.rst	2014-12-22 17:38:14 +0000
@@ -572,7 +572,7 @@
     <User "Elly Person" (...) at ...>
 
     >>> set(member.list_id for member in elly.memberships.members)
-    set([u'ant.example.com'])
+    {'ant.example.com'}
 
     >>> dump_json('http://localhost:9001/3.0/members')
     entry 0:
@@ -674,7 +674,7 @@
 Elly is no longer a member of the mailing list.
 
     >>> set(member.mailing_list for member in elly.memberships.members)
-    set([])
+    set()
 
 
 Digest delivery

=== modified file 'src/mailman/rest/docs/moderation.rst'
--- src/mailman/rest/docs/moderation.rst	2014-10-31 03:12:00 +0000
+++ src/mailman/rest/docs/moderation.rst	2014-12-22 17:38:14 +0000
@@ -141,13 +141,6 @@
     server: ...
     status: 204
 
-After which, the message is gone from the moderation queue.
-
-    >>> dump_json(url(request_id))
-    Traceback (most recent call last):
-    ...
-    HTTPError: HTTP Error 404: 404 Not Found
-
 Messages can also be accepted via the REST API.  Let's hold a new message for
 moderation.
 ::

=== modified file 'src/mailman/rest/docs/preferences.rst'
--- src/mailman/rest/docs/preferences.rst	2014-11-08 15:27:56 +0000
+++ src/mailman/rest/docs/preferences.rst	2014-12-22 17:38:14 +0000
@@ -162,7 +162,7 @@
     >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com'
     ...           '/preferences')
     acknowledge_posts: True
-    http_etag: "1ff07b0367bede79ade27d217e12df3915aaee2b"
+    http_etag: "..."
     preferred_language: ja
     self_link: http://localhost:9001/3.0/addresses/anne@example.com/preferences
 

=== modified file 'src/mailman/rest/docs/users.rst'
--- src/mailman/rest/docs/users.rst	2014-12-08 15:43:08 +0000
+++ src/mailman/rest/docs/users.rst	2014-12-22 17:38:14 +0000
@@ -277,27 +277,6 @@
     server: ...
     status: 204
 
-Cris's resource cannot be retrieved either by email address...
-
-    >>> dump_json('http://localhost:9001/3.0/users/cris@example.com')
-    Traceback (most recent call last):
-    ...
-    HTTPError: HTTP Error 404: 404 Not Found
-
-...or user id.
-
-    >>> dump_json('http://localhost:9001/3.0/users/3')
-    Traceback (most recent call last):
-    ...
-    HTTPError: HTTP Error 404: 404 Not Found
-
-Cris's address records no longer exist either.
-
-    >>> dump_json('http://localhost:9001/3.0/addresses/cris@example.com')
-    Traceback (most recent call last):
-    ...
-    HTTPError: HTTP Error 404: 404 Not Found
-
 
 User addresses
 ==============
@@ -420,12 +399,3 @@
     date: ...
     server: ...
     status: 204
-
-But this time, she is unsuccessful.
-
-    >>> dump_json('http://localhost:9001/3.0/users/5/login', {
-    ...           'cleartext_password': 'not-the-password',
-    ...           }, method='POST')
-    Traceback (most recent call last):
-    ...
-    HTTPError: HTTP Error 403: 403 Forbidden

=== modified file 'src/mailman/rest/domains.py'
--- src/mailman/rest/domains.py	2014-08-14 18:23:14 +0000
+++ src/mailman/rest/domains.py	2014-12-22 17:38:14 +0000
@@ -26,6 +26,8 @@
     ]
 
 
+import six
+
 from mailman.interfaces.domain import (
     BadDomainSpecificationError, IDomainManager)
 from mailman.rest.helpers import (
@@ -99,10 +101,10 @@
         """Create a new domain."""
         domain_manager = getUtility(IDomainManager)
         try:
-            validator = Validator(mail_host=unicode,
-                                  description=unicode,
-                                  base_url=unicode,
-                                  contact_address=unicode,
+            validator = Validator(mail_host=six.text_type,
+                                  description=six.text_type,
+                                  base_url=six.text_type,
+                                  contact_address=six.text_type,
                                   _optional=('description', 'base_url',
                                              'contact_address'))
             domain = domain_manager.add(**validator(request))

=== modified file 'src/mailman/rest/helpers.py'
--- src/mailman/rest/helpers.py	2014-11-08 15:27:56 +0000
+++ src/mailman/rest/helpers.py	2014-12-22 17:38:14 +0000
@@ -59,7 +59,7 @@
     :return: The full path to the resource.
     :rtype: bytes
     """
-    return b'{0}://{1}:{2}/{3}/{4}'.format(
+    return '{0}://{1}:{2}/{3}/{4}'.format(
         ('https' if as_boolean(config.webservice.use_https) else 'http'),
         config.webservice.hostname,
         config.webservice.port,
@@ -107,8 +107,10 @@
     assert 'http_etag' not in resource, 'Resource already etagged'
     # Calculate the tag from a predictable (i.e. sorted) representation of the
     # dictionary.  The actual details aren't so important.  pformat() is
-    # guaranteed to sort the keys.
-    etag = hashlib.sha1(pformat(resource)).hexdigest()
+    # guaranteed to sort the keys, however it returns a str and the hash
+    # library requires a bytes.  Use the safest possible encoding.
+    hashfood = pformat(resource).encode('raw-unicode-escape')
+    etag = hashlib.sha1(hashfood).hexdigest()
     resource['http_etag'] = '"{0}"'.format(etag)
     return json.dumps(resource, cls=ExtendedEncoder)
 

=== modified file 'src/mailman/rest/lists.py'
--- src/mailman/rest/lists.py	2014-08-14 18:23:14 +0000
+++ src/mailman/rest/lists.py	2014-12-22 17:38:14 +0000
@@ -30,6 +30,8 @@
     ]
 
 
+import six
+
 from lazr.config import as_boolean
 from operator import attrgetter
 from zope.component import getUtility
@@ -204,16 +206,15 @@
     def on_post(self, request, response):
         """Create a new mailing list."""
         try:
-            validator = Validator(fqdn_listname=unicode,
-                                  style_name=unicode,
+            validator = Validator(fqdn_listname=six.text_type,
+                                  style_name=six.text_type,
                                   _optional=('style_name',))
             mlist = create_list(**validator(request))
         except ListAlreadyExistsError:
             bad_request(response, b'Mailing list exists')
         except BadDomainSpecificationError as error:
-            bad_request(
-                response,
-                b'Domain does not exist: {0}'.format(error.domain))
+            reason = 'Domain does not exist: {}'.format(error.domain)
+            bad_request(response, reason.encode('utf-8'))
         except ValueError as error:
             bad_request(response, str(error))
         else:
@@ -273,7 +274,7 @@
         # attribute will contain the (bytes) name of the archiver that is
         # getting a new status.  value will be the representation of the new
         # boolean status.
-        archiver = self._archiver_set.get(attribute.decode('utf-8'))
+        archiver = self._archiver_set.get(attribute)
         if archiver is None:
             raise ValueError('No such archiver: {}'.format(attribute))
         archiver.is_enabled = as_boolean(value)

=== modified file 'src/mailman/rest/members.py'
--- src/mailman/rest/members.py	2014-08-14 18:23:14 +0000
+++ src/mailman/rest/members.py	2014-12-22 17:38:14 +0000
@@ -28,6 +28,8 @@
     ]
 
 
+import six
+
 from uuid import UUID
 from operator import attrgetter
 from zope.component import getUtility
@@ -176,7 +178,7 @@
             return
         try:
             values = Validator(
-                address=unicode,
+                address=six.text_type,
                 delivery_mode=enum_validator(DeliveryMode),
                 _optional=('address', 'delivery_mode'))(request)
         except ValueError as error:
@@ -207,9 +209,9 @@
         service = getUtility(ISubscriptionService)
         try:
             validator = Validator(
-                list_id=unicode,
+                list_id=six.text_type,
                 subscriber=subscriber_validator,
-                display_name=unicode,
+                display_name=six.text_type,
                 delivery_mode=enum_validator(DeliveryMode),
                 role=enum_validator(MemberRole),
                 _optional=('delivery_mode', 'display_name', 'role'))
@@ -256,8 +258,8 @@
         """Find a member"""
         service = getUtility(ISubscriptionService)
         validator = Validator(
-            list_id=unicode,
-            subscriber=unicode,
+            list_id=six.text_type,
+            subscriber=six.text_type,
             role=enum_validator(MemberRole),
             _optional=('list_id', 'subscriber', 'role'))
         try:

=== modified file 'src/mailman/rest/moderation.py'
--- src/mailman/rest/moderation.py	2014-08-14 18:23:14 +0000
+++ src/mailman/rest/moderation.py	2014-12-22 17:38:14 +0000
@@ -88,7 +88,7 @@
         # resource.  Others we can drop.  Since we're mutating the dictionary,
         # we need to make a copy of the keys.  When you port this to Python 3,
         # you'll need to list()-ify the .keys() dictionary view.
-        for key in resource.keys():
+        for key in list(resource):
             if key in ('_mod_subject', '_mod_hold_date', '_mod_reason',
                        '_mod_sender', '_mod_message_id'):
                 resource[key[5:]] = resource.pop(key)

=== modified file 'src/mailman/rest/root.py'
--- src/mailman/rest/root.py	2014-08-14 18:23:14 +0000
+++ src/mailman/rest/root.py	2014-12-22 17:38:14 +0000
@@ -66,17 +66,18 @@
         # the case where no error is raised.
         if request.auth is None:
             raise falcon.HTTPUnauthorized(
-                b'401 Unauthorized',
-                b'The REST API requires authentication')
+                '401 Unauthorized',
+                'The REST API requires authentication')
         if request.auth.startswith('Basic '):
-            credentials = b64decode(request.auth[6:])
+            # b64decode() returns bytes, but we require a str.
+            credentials = b64decode(request.auth[6:]).decode('utf-8')
             username, password = credentials.split(':', 1)
             if (username != config.webservice.admin_user or
                 password != config.webservice.admin_pass):
                 # Not authorized.
                 raise falcon.HTTPUnauthorized(
-                    b'401 Unauthorized',
-                    b'User is not authorized for the REST API')
+                    '401 Unauthorized',
+                    'User is not authorized for the REST API')
         return TopLevel()
 
 

=== modified file 'src/mailman/rest/tests/test_addresses.py'
--- src/mailman/rest/tests/test_addresses.py	2014-12-08 22:29:19 +0000
+++ src/mailman/rest/tests/test_addresses.py	2014-12-22 17:38:14 +0000
@@ -27,15 +27,14 @@
 
 import unittest
 
-from urllib2 import HTTPError
-from zope.component import getUtility
-
 from mailman.app.lifecycle import create_list
 from mailman.database.transaction import transaction
 from mailman.interfaces.usermanager import IUserManager
 from mailman.testing.helpers import call_api
 from mailman.testing.layers import RESTLayer
 from mailman.utilities.datetime import now
+from six.moves.urllib_error import HTTPError
+from zope.component import getUtility
 
 
 
@@ -53,6 +52,12 @@
         self.assertEqual(json['start'], 0)
         self.assertEqual(json['total_size'], 0)
 
+    def test_missing_address(self):
+        # An address that isn't registered yet cannot be retrieved.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/addresses/nobody@example.com')
+        self.assertEqual(cm.exception.code, 404)
+
     def test_membership_of_missing_address(self):
         # Try to get the memberships of a missing address.
         with self.assertRaises(HTTPError) as cm:
@@ -166,7 +171,7 @@
                      'email': 'anne@example.com',
                      })
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.reason, 'Address already exists')
+        self.assertEqual(cm.exception.reason, b'Address already exists')
 
     def test_invalid_address_bad_request(self):
         # Trying to add an invalid address string returns 400.
@@ -178,7 +183,7 @@
                      'email': 'invalid_address_string'
                      })
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.reason, 'Invalid email address')
+        self.assertEqual(cm.exception.reason, b'Invalid email address')
 
     def test_empty_address_bad_request(self):
         # The address is required.
@@ -189,7 +194,7 @@
                 'http://localhost:9001/3.0/users/anne@example.com/addresses',
                 {})
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.reason, 'Missing parameters: email')
+        self.assertEqual(cm.exception.reason, b'Missing parameters: email')
 
     def test_get_addresses_of_missing_user(self):
         # There is no user associated with the given address.

=== modified file 'src/mailman/rest/tests/test_domains.py'
--- src/mailman/rest/tests/test_domains.py	2014-08-13 14:30:13 +0000
+++ src/mailman/rest/tests/test_domains.py	2014-12-22 17:38:14 +0000
@@ -27,14 +27,13 @@
 
 import unittest
 
-from urllib2 import HTTPError
-from zope.component import getUtility
-
 from mailman.app.lifecycle import create_list
 from mailman.database.transaction import transaction
 from mailman.interfaces.listmanager import IListManager
 from mailman.testing.helpers import call_api
 from mailman.testing.layers import RESTLayer
+from six.moves.urllib_error import HTTPError
+from zope.component import getUtility
 
 
 
@@ -65,7 +64,7 @@
         content, response = call_api(
             'http://localhost:9001/3.0/domains/example.com', method='DELETE')
         self.assertEqual(response.status, 204)
-        self.assertEqual(getUtility(IListManager).get('ant@example.com'), None)
+        self.assertIsNone(getUtility(IListManager).get('ant@example.com'))
 
     def test_missing_domain(self):
         # You get a 404 if you try to access a nonexisting domain.
@@ -80,3 +79,14 @@
             call_api(
                 'http://localhost:9001/3.0/domains/does-not-exist.com/lists')
         self.assertEqual(cm.exception.code, 404)
+
+    def test_double_delete(self):
+        # You cannot delete a domain twice.
+        content, response = call_api(
+            'http://localhost:9001/3.0/domains/example.com',
+            method='DELETE')
+        self.assertEqual(response.status, 204)
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/domains/example.com',
+                     method='DELETE')
+        self.assertEqual(cm.exception.code, 404)

=== modified file 'src/mailman/rest/tests/test_lists.py'
--- src/mailman/rest/tests/test_lists.py	2014-08-14 17:43:35 +0000
+++ src/mailman/rest/tests/test_lists.py	2014-12-22 17:38:14 +0000
@@ -30,14 +30,13 @@
 
 import unittest
 
-from urllib2 import HTTPError
-from zope.component import getUtility
-
 from mailman.app.lifecycle import create_list
 from mailman.database.transaction import transaction
 from mailman.interfaces.usermanager import IUserManager
 from mailman.testing.helpers import call_api
 from mailman.testing.layers import RESTLayer
+from six.moves.urllib_error import HTTPError
+from zope.component import getUtility
 
 
 
@@ -129,7 +128,7 @@
                      })
         self.assertEqual(cm.exception.code, 400)
         self.assertEqual(cm.exception.reason,
-                         'Domain does not exist: no-domain.example.org')
+                         b'Domain does not exist: no-domain.example.org')
 
     def test_cannot_create_duplicate_list(self):
         # You cannot create a list that already exists.
@@ -141,7 +140,7 @@
                      'fqdn_listname': 'ant@example.com',
                      })
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.reason, 'Mailing list exists')
+        self.assertEqual(cm.exception.reason, b'Mailing list exists')
 
     def test_cannot_delete_missing_list(self):
         # You cannot delete a list that does not exist.
@@ -220,7 +219,7 @@
                 method='PATCH')
         self.assertEqual(cm.exception.code, 400)
         self.assertEqual(cm.exception.reason,
-                         'Unexpected parameters: bogus-archiver')
+                         b'Unexpected parameters: bogus-archiver')
 
     def test_put_incomplete_statuses(self):
         # PUT requires the full resource representation.  This one forgets to
@@ -233,7 +232,7 @@
                 method='PUT')
         self.assertEqual(cm.exception.code, 400)
         self.assertEqual(cm.exception.reason,
-                         'Missing parameters: mhonarc, prototype')
+                         b'Missing parameters: mhonarc, prototype')
 
     def test_patch_bogus_status(self):
         # Archiver statuses must be interpretable as booleans.
@@ -246,7 +245,7 @@
                     },
                 method='PATCH')
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.reason, 'Invalid boolean value: sure')
+        self.assertEqual(cm.exception.reason, b'Invalid boolean value: sure')
 
 
 

=== modified file 'src/mailman/rest/tests/test_membership.py'
--- src/mailman/rest/tests/test_membership.py	2014-08-14 17:43:35 +0000
+++ src/mailman/rest/tests/test_membership.py	2014-12-22 17:38:14 +0000
@@ -28,9 +28,6 @@
 
 import unittest
 
-from urllib2 import HTTPError
-from zope.component import getUtility
-
 from mailman.app.lifecycle import create_list
 from mailman.config import config
 from mailman.database.transaction import transaction
@@ -41,6 +38,8 @@
 from mailman.runners.incoming import IncomingRunner
 from mailman.testing.layers import ConfigLayer, RESTLayer
 from mailman.utilities.datetime import now
+from six.moves.urllib_error import HTTPError
+from zope.component import getUtility
 
 
 
@@ -60,7 +59,7 @@
                 'subscriber': 'nobody@example.com',
                 })
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.msg, 'No such list')
+        self.assertEqual(cm.exception.reason, b'No such list')
 
     def test_try_to_leave_missing_list(self):
         # A user tries to leave a non-existent list.
@@ -100,7 +99,7 @@
                 'subscriber': 'anne@example.com',
                 })
         self.assertEqual(cm.exception.code, 409)
-        self.assertEqual(cm.exception.msg, 'Member already subscribed')
+        self.assertEqual(cm.exception.reason, b'Member already subscribed')
 
     def test_join_with_invalid_delivery_mode(self):
         with self.assertRaises(HTTPError) as cm:
@@ -111,8 +110,8 @@
                 'delivery_mode': 'invalid-mode',
                 })
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.msg,
-                         'Cannot convert parameters: delivery_mode')
+        self.assertEqual(cm.exception.reason,
+                         b'Cannot convert parameters: delivery_mode')
 
     def test_join_email_contains_slash(self):
         content, response = call_api('http://localhost:9001/3.0/members', {
@@ -204,7 +203,7 @@
                      'powers': 'super',
                      }, method='PATCH')
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.msg, 'Unexpected parameters: powers')
+        self.assertEqual(cm.exception.reason, b'Unexpected parameters: powers')
 
     def test_member_all_without_preferences(self):
         # /members/<id>/all should return a 404 when it isn't trailed by

=== modified file 'src/mailman/rest/tests/test_moderation.py'
--- src/mailman/rest/tests/test_moderation.py	2014-01-01 14:59:42 +0000
+++ src/mailman/rest/tests/test_moderation.py	2014-12-22 17:38:14 +0000
@@ -26,8 +26,6 @@
 
 import unittest
 
-from urllib2 import HTTPError
-
 from mailman.app.lifecycle import create_list
 from mailman.app.moderator import hold_message, hold_subscription
 from mailman.config import config
@@ -36,6 +34,7 @@
 from mailman.testing.helpers import (
     call_api, specialized_message_from_string as mfs)
 from mailman.testing.layers import RESTLayer
+from six.moves.urllib_error import HTTPError
 
 
 
@@ -97,7 +96,8 @@
         with self.assertRaises(HTTPError) as cm:
             call_api(url.format(held_id), {'action': 'bogus'})
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.msg, 'Cannot convert parameters: action')
+        self.assertEqual(cm.exception.msg,
+                         b'Cannot convert parameters: action')
 
     def test_bad_subscription_request_id(self):
         # Bad request when request_id is not an integer.
@@ -123,4 +123,18 @@
         with self.assertRaises(HTTPError) as cm:
             call_api(url.format(held_id), {'action': 'bogus'})
         self.assertEqual(cm.exception.code, 400)
-        self.assertEqual(cm.exception.msg, 'Cannot convert parameters: action')
+        self.assertEqual(cm.exception.msg,
+                         b'Cannot convert parameters: action')
+
+    def test_discard(self):
+        # Discarding a message removes it from the moderation queue.
+        with transaction():
+            held_id = hold_message(self._mlist, self._msg)
+        url = 'http://localhost:9001/3.0/lists/ant@example.com/held/{}'.format(
+            held_id)
+        content, response = call_api(url, dict(action='discard'))
+        self.assertEqual(response.status, 204)
+        # Now it's gone.
+        with self.assertRaises(HTTPError) as cm:
+            call_api(url, dict(action='discard'))
+        self.assertEqual(cm.exception.code, 404)

=== modified file 'src/mailman/rest/tests/test_preferences.py'
--- src/mailman/rest/tests/test_preferences.py	2014-08-13 04:18:23 +0000
+++ src/mailman/rest/tests/test_preferences.py	2014-12-22 17:38:14 +0000
@@ -32,7 +32,7 @@
 from mailman.interfaces.usermanager import IUserManager
 from mailman.testing.helpers import call_api
 from mailman.testing.layers import RESTLayer
-from urllib2 import HTTPError
+from six.moves.urllib_error import HTTPError
 from zope.component import getUtility
 
 

=== modified file 'src/mailman/rest/tests/test_root.py'
--- src/mailman/rest/tests/test_root.py	2014-08-12 23:00:44 +0000
+++ src/mailman/rest/tests/test_root.py	2014-12-22 17:38:14 +0000
@@ -35,7 +35,7 @@
 from mailman.core.system import system
 from mailman.testing.helpers import call_api
 from mailman.testing.layers import RESTLayer
-from urllib2 import HTTPError
+from six.moves.urllib_error import HTTPError
 
 
 
@@ -102,22 +102,23 @@
             }
         response, raw_content = Http().request(url, 'GET', None, headers)
         self.assertEqual(response.status, 401)
-        content = json.loads(raw_content)
+        content = json.loads(raw_content.decode('utf-8'))
         self.assertEqual(content['title'], '401 Unauthorized')
         self.assertEqual(content['description'],
                          'The REST API requires authentication')
 
     def test_unauthorized(self):
         # Bad Basic Auth credentials results in a 401 error.
-        auth = b64encode('baduser:badpass')
+        userpass = b64encode(b'baduser:badpass')
+        auth = 'Basic {}'.format(userpass.decode('ascii'))
         url = 'http://localhost:9001/3.0/system'
         headers = {
             'Content-Type': 'application/x-www-form-urlencode',
-            'Authorization': 'Basic ' + auth,
+            'Authorization': auth,
             }
         response, raw_content = Http().request(url, 'GET', None, headers)
         self.assertEqual(response.status, 401)
-        content = json.loads(raw_content)
+        content = json.loads(raw_content.decode('utf-8'))
         self.assertEqual(content['title'], '401 Unauthorized')
         self.assertEqual(content['description'],
                          'User is not authorized for the REST API')

=== modified file 'src/mailman/rest/tests/test_users.py'
--- src/mailman/rest/tests/test_users.py	2014-04-15 21:54:35 +0000
+++ src/mailman/rest/tests/test_users.py	2014-12-22 17:38:14 +0000
@@ -30,15 +30,14 @@
 import os
 import unittest
 
-from urllib2 import HTTPError
-from zope.component import getUtility
-
 from mailman.app.lifecycle import create_list
 from mailman.config import config
 from mailman.database.transaction import transaction
 from mailman.interfaces.usermanager import IUserManager
 from mailman.testing.helpers import call_api, configuration
 from mailman.testing.layers import RESTLayer
+from six.moves.urllib_error import HTTPError
+from zope.component import getUtility
 
 
 
@@ -108,6 +107,48 @@
                      method='DELETE')
         self.assertEqual(cm.exception.code, 404)
 
+    def test_delete_user_twice(self):
+        # You cannot DELETE a user twice, either by address or user id.
+        with transaction():
+            anne = getUtility(IUserManager).create_user(
+                'anne@example.com', 'Anne Person')
+            user_id = anne.user_id
+        content, response = call_api(
+            'http://localhost:9001/3.0/users/anne@example.com',
+            method='DELETE')
+        self.assertEqual(response.status, 204)
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/users/anne@example.com',
+                     method='DELETE')
+        self.assertEqual(cm.exception.code, 404)
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/users/{}'.format(user_id),
+                     method='DELETE')
+        self.assertEqual(cm.exception.code, 404)
+
+    def test_get_after_delete(self):
+        # You cannot GET a user record after deleting them.
+        with transaction():
+            anne = getUtility(IUserManager).create_user(
+                'anne@example.com', 'Anne Person')
+            user_id = anne.user_id
+        # You can still GET the user record.
+        content, response = call_api(
+            'http://localhost:9001/3.0/users/anne@example.com')
+        self.assertEqual(response.status, 200)
+        # Delete the user.
+        content, response = call_api(
+            'http://localhost:9001/3.0/users/anne@example.com',
+            method='DELETE')
+        self.assertEqual(response.status, 204)
+        # The user record can no longer be retrieved.
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/users/anne@example.com')
+        self.assertEqual(cm.exception.code, 404)
+        with self.assertRaises(HTTPError) as cm:
+            call_api('http://localhost:9001/3.0/users/{}'.format(user_id))
+        self.assertEqual(cm.exception.code, 404)
+
     def test_existing_user_error(self):
         # Creating a user twice results in an error.
         call_api('http://localhost:9001/3.0/users', {
@@ -120,7 +161,7 @@
                      })
         self.assertEqual(cm.exception.code, 400)
         self.assertEqual(cm.exception.reason,
-                         'Address already exists: anne@example.com')
+                         b'Address already exists: anne@example.com')
 
     def test_addresses_of_missing_user_id(self):
         # Trying to get the /addresses of a missing user id results in error.
@@ -251,6 +292,21 @@
                 'anne@example.com', 'Anne Person')
             self.anne.password = config.password_context.encrypt('abc123')
 
+    def test_login_with_cleartext_password(self):
+        # A user can log in with the correct clear text password.
+        content, response = call_api(
+            'http://localhost:9001/3.0/users/anne@example.com/login', {
+                'cleartext_password': 'abc123',
+                }, method='POST')
+        self.assertEqual(response.status, 204)
+        # But the user cannot log in with an incorrect password.
+        with self.assertRaises(HTTPError) as cm:
+            call_api(
+                'http://localhost:9001/3.0/users/anne@example.com/login', {
+                    'cleartext_password': 'not-the-password',
+                    }, method='POST')
+        self.assertEqual(cm.exception.code, 403)
+
     def test_wrong_parameter(self):
         # A bad request because it is mistyped the required attribute.
         with self.assertRaises(HTTPError) as cm:

=== modified file 'src/mailman/rest/users.py'
--- src/mailman/rest/users.py	2014-12-08 22:29:19 +0000
+++ src/mailman/rest/users.py	2014-12-22 17:38:14 +0000
@@ -27,7 +27,12 @@
     ]
 
 
+<<<<<<< TREE
 from lazr.config import as_boolean
+=======
+import six
+
+>>>>>>> MERGE-SOURCE
 from passlib.utils import generate_password as generate
 from uuid import UUID
 from zope.component import getUtility
@@ -60,7 +65,7 @@
 
 
 ATTRIBUTES = dict(
-    display_name=GetterSetter(unicode),
+    display_name=GetterSetter(six.text_type),
     cleartext_password=PasswordEncrypterGetterSetter(),
     )
 
@@ -136,12 +141,38 @@
     def on_post(self, request, response):
         """Create a new user."""
         try:
+<<<<<<< TREE
             validator = Validator(**CREATION_FIELDS)
+=======
+            validator = Validator(email=six.text_type,
+                                  display_name=six.text_type,
+                                  password=six.text_type,
+                                  _optional=('display_name', 'password'))
+>>>>>>> MERGE-SOURCE
             arguments = validator(request)
         except ValueError as error:
             bad_request(response, str(error))
             return
+<<<<<<< TREE
         create_user(arguments, response)
+=======
+        # 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)
+>>>>>>> MERGE-SOURCE
 
 
 
@@ -360,7 +391,7 @@
         # 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(unicode))
+        validator = Validator(cleartext_password=GetterSetter(six.text_type))
         try:
             values = validator(request)
         except ValueError as error:

=== modified file 'src/mailman/rest/validator.py'
--- src/mailman/rest/validator.py	2014-11-02 19:55:10 +0000
+++ src/mailman/rest/validator.py	2014-12-22 17:38:14 +0000
@@ -62,7 +62,7 @@
     try:
         return UUID(int=int(subscriber))
     except ValueError:
-        return unicode(subscriber)
+        return subscriber
 
 
 def language_validator(code):

=== modified file 'src/mailman/rest/wsgiapp.py'
--- src/mailman/rest/wsgiapp.py	2014-11-11 22:52:12 +0000
+++ src/mailman/rest/wsgiapp.py	2014-12-22 17:38:14 +0000
@@ -85,7 +85,7 @@
                 if matcher is _missing:
                     continue
                 result = None
-                if isinstance(matcher, basestring):
+                if isinstance(matcher, str):
                     # Is the matcher string a regular expression or plain
                     # string?  If it starts with a caret, it's a regexp.
                     if matcher.startswith('^'):

=== modified file 'src/mailman/rules/administrivia.py'
--- src/mailman/rules/administrivia.py	2014-11-20 01:29:44 +0000
+++ src/mailman/rules/administrivia.py	2014-12-22 17:38:14 +0000
@@ -74,7 +74,7 @@
         # Search only the first text/plain subpart of the message.  There's
         # really no good way to find email commands in any other content type.
         for part in typed_subpart_iterator(msg, 'text', 'plain'):
-            payload = part.get_payload(decode=True)
+            payload = part.get_payload()
             lines = payload.splitlines()
             # Count lines without using enumerate() because blank lines in the
             # payload don't count against the maximum examined.

=== modified file 'src/mailman/rules/approved.py'
--- src/mailman/rules/approved.py	2014-12-04 02:08:42 +0000
+++ src/mailman/rules/approved.py	2014-12-22 17:38:14 +0000
@@ -113,7 +113,7 @@
                 # may not work with rtf or whatever else is possible.
                 pattern = header + ':(\s|&nbsp;)*' + re.escape(password)
                 for part in typed_subpart_iterator(msg, 'text'):
-                    payload = part.get_payload(decode=True)
+                    payload = part.get_payload()
                     if payload is not None:
                         if re.search(pattern, payload):
                             reset_payload(part, re.sub(pattern, '', payload))

=== modified file 'src/mailman/rules/tests/test_approved.py'
--- src/mailman/rules/tests/test_approved.py	2014-12-04 02:08:42 +0000
+++ src/mailman/rules/tests/test_approved.py	2014-12-22 17:38:14 +0000
@@ -433,7 +433,7 @@
         # hash is chosen after the original password is set.  As long as the
         # old password still validates, the migration happens automatically.
         self._mlist.moderator_password = config.password_context.encrypt(
-            b'super secret')
+            'super secret')
         self._rule = approved.Approved()
         self._msg = mfs("""\
 From: anne@example.com
@@ -450,7 +450,7 @@
         # hashing algorithm.  When the old password is validated, it will be
         # automatically migrated to the new hash.
         self.assertEqual(self._mlist.moderator_password,
-                         b'{plaintext}super secret')
+                         '{plaintext}super secret')
         config_file = os.path.join(config.VAR_DIR, 'passlib.config')
         # XXX passlib seems to choose the default hashing scheme even if it is
         # deprecated.  The default scheme is either specified explicitly, or
@@ -466,14 +466,14 @@
             self._msg['Approved'] = 'super secret'
             result = self._rule.check(self._mlist, self._msg, {})
             self.assertTrue(result)
-        self.assertEqual(self._mlist.moderator_password, b'super secret')
+        self.assertEqual(self._mlist.moderator_password, 'super secret')
 
     def test_invalid_password_does_not_migrate(self):
         # Now that the moderator password is set, change the default password
         # hashing algorithm.  When the old password is invalid, it will not be
         # automatically migrated to the new hash.
         self.assertEqual(self._mlist.moderator_password,
-                         b'{plaintext}super secret')
+                         '{plaintext}super secret')
         config_file = os.path.join(config.VAR_DIR, 'passlib.config')
         # XXX passlib seems to choose the default hashing scheme even if it is
         # deprecated.  The default scheme is either specified explicitly, or
@@ -490,6 +490,7 @@
             result = self._rule.check(self._mlist, self._msg, {})
             self.assertFalse(result)
         self.assertEqual(self._mlist.moderator_password,
+<<<<<<< TREE
                          b'{plaintext}super secret')
 
 
@@ -523,3 +524,6 @@
 """)
         result = self._rule.check(self._mlist, msg, {})
         self.assertFalse(result)
+=======
+                         '{plaintext}super secret')
+>>>>>>> MERGE-SOURCE

=== modified file 'src/mailman/runners/command.py'
--- src/mailman/runners/command.py	2014-11-20 01:29:44 +0000
+++ src/mailman/runners/command.py	2014-12-22 17:38:14 +0000
@@ -31,9 +31,9 @@
 # -owner.
 
 import re
+import six
 import logging
 
-from StringIO import StringIO
 from email.errors import HeaderParseError
 from email.header import decode_header, make_header
 from email.iterators import typed_subpart_iterator
@@ -76,7 +76,7 @@
         # Extract the subject header and do RFC 2047 decoding.
         raw_subject = msg.get('subject', '')
         try:
-            subject = unicode(make_header(decode_header(raw_subject)))
+            subject = str(make_header(decode_header(raw_subject)))
             # Mail commands must be ASCII.
             self.command_lines.append(subject.encode('us-ascii'))
         except (HeaderParseError, UnicodeError, LookupError):
@@ -84,7 +84,7 @@
             # subject is a unicode object, convert it to ASCII ignoring all
             # bogus characters.  Otherwise, there's nothing in the subject
             # that we can use.
-            if isinstance(raw_subject, unicode):
+            if isinstance(raw_subject, six.text_type):
                 safe_subject = raw_subject.encode('us-ascii', 'ignore')
                 self.command_lines.append(safe_subject)
         # Find the first text/plain part of the message.
@@ -98,9 +98,9 @@
         if part is None:
             # There was no text/plain part to be found.
             return
-        body = part.get_payload(decode=True)
+        body = part.get_payload()
         # text/plain parts better have string payloads.
-        assert isinstance(body, basestring), 'Non-string decoded payload'
+        assert isinstance(body, six.string_types), 'Non-string decoded payload'
         lines = body.splitlines()
         # Use no more lines than specified
         max_lines = int(config.mailman.email_commands_max_lines)
@@ -118,7 +118,7 @@
             # Ensure that all the parts are unicodes.  Since we only accept
             # ASCII commands and arguments, ignore anything else.
             parts = [(part
-                      if isinstance(part, unicode)
+                      if isinstance(part, six.text_type)
                       else part.decode('ascii', 'ignore'))
                      for part in parts]
             yield parts
@@ -130,20 +130,20 @@
     """The email command results."""
 
     def __init__(self, charset='us-ascii'):
-        self._output = StringIO()
+        self._output = six.StringIO()
         self.charset = charset
         print(_("""\
 The results of your email command are provided below.
 """), file=self._output)
 
     def write(self, text):
-        if not isinstance(text, unicode):
+        if isinstance(text, bytes):
             text = text.decode(self.charset, 'ignore')
         self._output.write(text)
 
-    def __unicode__(self):
+    def __str__(self):
         value = self._output.getvalue()
-        assert isinstance(value, unicode), 'Not a unicode: %r' % value
+        assert isinstance(value, six.text_type), 'Not a unicode: %r' % value
         return value
 
 
@@ -207,12 +207,12 @@
                 if status == ContinueProcessing.no:
                     break
         # All done.  Strip blank lines and send the response.
-        lines = filter(None, (line.strip() for line in finder.command_lines))
+        lines = [line.strip() for line in finder.command_lines if line]
         if len(lines) > 0:
             print(_('\n- Unprocessed:'), file=results)
             for line in lines:
                 print(line, file=results)
-        lines = filter(None, (line.strip() for line in finder.ignored_lines))
+        lines = [line.strip() for line in finder.ignored_lines if line]
         if len(lines) > 0:
             print(_('\n- Ignored:'), file=results)
             for line in lines:
@@ -231,7 +231,7 @@
         # Find a charset for the response body.  Try the original message's
         # charset first, then ascii, then latin-1 and finally falling back to
         # utf-8.
-        reply_body = unicode(results)
+        reply_body = str(results)
         for charset in (results.charset, 'us-ascii', 'latin-1'):
             try:
                 reply_body.encode(charset)

=== modified file 'src/mailman/runners/digest.py'
--- src/mailman/runners/digest.py	2014-12-11 02:49:39 +0000
+++ src/mailman/runners/digest.py	2014-12-22 17:38:14 +0000
@@ -28,15 +28,11 @@
 import re
 import logging
 
-# cStringIO doesn't support unicode.
-from StringIO import StringIO
 from copy import deepcopy
 from email.header import Header
 from email.mime.message import MIMEMessage
 from email.mime.text import MIMEText
 from email.utils import formatdate, getaddresses, make_msgid
-from urllib2 import URLError
-
 from mailman.config import config
 from mailman.core.i18n import _
 from mailman.core.runner import Runner
@@ -46,6 +42,8 @@
 from mailman.utilities.i18n import make
 from mailman.utilities.mailbox import Mailbox
 from mailman.utilities.string import oneline, wrap
+from six.moves import cStringIO as StringIO
+from six.moves.urllib_error import URLError
 
 
 log = logging.getLogger('mailman.error')
@@ -260,17 +258,16 @@
         # multipart message.  In that case, just stringify it.
         payload = msg.get_payload(decode=True)
         if not payload:
-            # Split using bytes so as not to turn the payload into unicode
-            # strings due to unicode_literals above.
-            payload = msg.as_string().split(b'\n\n', 1)[1]
-        try:
-            # Do the decoding inside the try/except so that if the charset
-            # conversion fails, we'll just drop back to ascii.
-            charset = msg.get_content_charset('us-ascii')
-            payload = payload.decode(charset, 'replace')
-        except (LookupError, TypeError):
-            # Unknown or empty charset.
-            payload = payload.decode('us-ascii', 'replace')
+            payload = msg.as_string().split('\n\n', 1)[1]
+        if isinstance(payload, bytes):
+            try:
+                # Do the decoding inside the try/except so that if the charset
+                # conversion fails, we'll just drop back to ascii.
+                charset = msg.get_content_charset('us-ascii')
+                payload = payload.decode(charset, 'replace')
+            except (LookupError, TypeError):
+                # Unknown or empty charset.
+                payload = payload.decode('us-ascii', 'replace')
         print(payload, file=self._text)
         if not payload.endswith('\n'):
             print(file=self._text)
@@ -384,9 +381,9 @@
         queue = config.switchboards['virgin']
         queue.enqueue(mime,
                       recipients=mime_recipients,
-                      listname=mlist.fqdn_listname,
+                      listid=mlist.list_id,
                       isdigest=True)
         queue.enqueue(rfc1153,
                       recipients=rfc1153_recipients,
-                      listname=mlist.fqdn_listname,
+                      listid=mlist.list_id,
                       isdigest=True)

=== modified file 'src/mailman/runners/docs/command.rst'
--- src/mailman/runners/docs/command.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/runners/docs/command.rst	2014-12-22 17:38:14 +0000
@@ -63,9 +63,9 @@
 
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg           : False
-    listname            : test@example.com
+    listid              : test.example.com
     nodecorate          : True
-    recipients          : set([u'aperson@example.com'])
+    recipients          : {'aperson@example.com'}
     reduced_list_headers: True
     version             : ...
 

=== modified file 'src/mailman/runners/docs/digester.rst'
--- src/mailman/runners/docs/digester.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/runners/docs/digester.rst	2014-12-22 17:38:14 +0000
@@ -57,10 +57,11 @@
     _parsemsg    : False
     digest_number: 1
     digest_path  : .../lists/test@example.com/digest.1.1.mmdf
-    listname     : test@example.com
+    listid       : test.example.com
     version      : 3
     volume       : 1
 
+..
     # Put the messages back in the queue for the runner to handle.
     >>> filebase = digestq.enqueue(entry.msg, entry.msgdata)
 
@@ -281,205 +282,6 @@
     <BLANKLINE>
 
 
-Internationalized digests
-=========================
-
-When messages come in with a content-type character set different than that of
-the list's preferred language, recipients will get an internationalized
-digest.  French is not enabled by default site-wide, so enable that now.
-::
-
-    # Simulate the site administrator setting the default server language to
-    # French in the configuration file.  Without this, the English template
-    # will be found and the masthead won't be translated.
-    >>> config.push('french', """
-    ... [mailman]
-    ... default_language: fr
-    ... """)
-
-    >>> mlist.preferred_language =  'fr'
-    >>> msg = message_from_string("""\
-    ... From: aperson@example.org
-    ... To: test@example.com
-    ... Subject: =?iso-2022-jp?b?GyRCMGxIVhsoQg==?=
-    ... MIME-Version: 1.0
-    ... Content-Type: text/plain; charset=iso-2022-jp
-    ... Content-Transfer-Encoding: 7bit
-    ...
-    ... \x1b$B0lHV\x1b(B
-    ... """)
-
-Set the digest threshold to zero so that the digests will be sent immediately.
-
-    >>> mlist.digest_size_threshold = 0
-    >>> process(mlist, msg, {})
-
-The marker message is sitting in the digest queue.
-
-    >>> len(digestq.files)
-    1
-    >>> entry = get_queue_messages('digest')[0]
-    >>> dump_msgdata(entry.msgdata)
-    _parsemsg    : False
-    digest_number: 2
-    digest_path  : .../lists/test@example.com/digest.1.2.mmdf
-    listname     : test@example.com
-    version      : 3
-    volume       : 1
-
-The digest runner runs a loop, placing the two digests into the virgin queue.
-
-    # Put the messages back in the queue for the runner to handle.
-    >>> filebase = digestq.enqueue(entry.msg, entry.msgdata)
-    >>> runner.run()
-    >>> messages = get_queue_messages('virgin')
-    >>> len(messages)
-    2
-
-One of which is the MIME digest and the other of which is the RFC 1153 digest.
-
-    >>> mime, rfc1153 = mime_rfc1153(messages)
-
-You can see that the digests contain a mix of French and Japanese.
-
-    >>> print(mime.msg.as_string())
-    Content-Type: multipart/mixed; boundary="===============...=="
-    MIME-Version: 1.0
-    From: test-request@example.com
-    Subject: Groupe Test, Vol 1, Parution 2
-    To: test@example.com
-    Reply-To: test@example.com
-    Date: ...
-    Message-ID: ...
-    <BLANKLINE>
-    --===============...==
-    Content-Type: text/plain; charset="iso-8859-1"
-    MIME-Version: 1.0
-    Content-Transfer-Encoding: quoted-printable
-    Content-Description: Groupe Test, Vol 1, Parution 2
-    <BLANKLINE>
-    Envoyez vos messages pour la liste Test =E0
-        test@example.com
-    <BLANKLINE>
-    Pour vous (d=E9s)abonner par le web, consultez
-        http://lists.example.com/listinfo/test@example.com
-    <BLANKLINE>
-    ou, par courriel, envoyez un message avec =AB=A0help=A0=BB dans le corps ou
-    dans le sujet =E0
-        test-request@example.com
-    <BLANKLINE>
-    Vous pouvez contacter l'administrateur de la liste =E0 l'adresse
-        test-owner@example.com
-    <BLANKLINE>
-    Si vous r=E9pondez, n'oubliez pas de changer l'objet du message afin
-    qu'il soit plus sp=E9cifique que =AB=A0Re: Contenu du groupe de Test...=A0=
-    =BB
-    --===============...==
-    Content-Type: text/plain; charset="utf-8"
-    MIME-Version: 1.0
-    Content-Transfer-Encoding: base64
-    Content-Description: Today's Topics (1 messages)
-    <BLANKLINE>
-    VGjDqG1lcyBkdSBqb3VyIDoKCiAgIDEuIOS4gOeVqiAoYXBlcnNvbkBleGFtcGxlLm9yZykK
-    <BLANKLINE>
-    --===============...==
-    Content-Type: message/rfc822
-    MIME-Version: 1.0
-    <BLANKLINE>
-    From: aperson@example.org
-    To: test@example.com
-    Subject: =?iso-2022-jp?b?GyRCMGxIVhsoQg==?=
-    MIME-Version: 1.0
-    Content-Type: text/plain; charset=iso-2022-jp
-    Content-Transfer-Encoding: 7bit
-    <BLANKLINE>
-    $B0lHV(B
-    <BLANKLINE>
-    --===============...==
-    Content-Type: text/plain; charset="iso-8859-1"
-    MIME-Version: 1.0
-    Content-Transfer-Encoding: quoted-printable
-    Content-Description: =?utf-8?q?Pied_de_page_des_remises_group=C3=A9es?=
-    <BLANKLINE>
-    _______________________________________________
-    Test mailing list
-    test@example.com
-    http://lists.example.com/listinfo/test@example.com
-    <BLANKLINE>
-    --===============...==--
-
-The RFC 1153 digest will be encoded in UTF-8 since it contains a mixture of
-French and Japanese characters.
-
-    >>> print(rfc1153.msg.as_string())
-    From: test-request@example.com
-    Subject: Groupe Test, Vol 1, Parution 2
-    To: test@example.com
-    Reply-To: test@example.com
-    Date: ...
-    Message-ID: ...
-    MIME-Version: 1.0
-    Content-Type: text/plain; charset="utf-8"
-    Content-Transfer-Encoding: base64
-    <BLANKLINE>
-    RW52b...
-    <BLANKLINE>
-
-The content can be decoded to see the actual digest text.
-::
-
-    # We must display the repr of the decoded value because doctests cannot
-    # handle the non-ascii characters.
-    >>> [repr(line)
-    ...  for line in rfc1153.msg.get_payload(decode=True).splitlines()]
-    ["'Envoyez vos messages pour la liste Test \\xc3\\xa0'",
-    "'\\ttest@example.com'",
-    "''",
-    "'Pour vous (d\\xc3\\xa9s)abonner par le web, consultez'",
-    "'\\thttp://lists.example.com/listinfo/test@example.com'",
-    "''",
-    "'ou, par courriel, envoyez un message avec \\xc2\\xab\\xc2\\xa0...
-    "'dans le sujet \\xc3\\xa0'",
-    "'\\ttest-request@example.com'",
-    "''",
-    '"Vous pouvez contacter l\'administrateur de la liste \\xc3\\xa0 ...
-    "'\\ttest-owner@example.com'",
-    "''",
-    '"Si vous r\\xc3\\xa9pondez, n\'oubliez pas de changer l\'objet du ...
-    '"qu\'il soit plus sp\\xc3\\xa9cifique que \\xc2\\xab\\xc2\\xa0Re: ...
-    "''",
-    "'Th\\xc3\\xa8mes du jour :'",
-    "''",
-    "'   1. \\xe4\\xb8\\x80\\xe7\\x95\\xaa (aperson@example.org)'",
-    "''",
-    "''",
-    "'---------------------------------------------------------------------...
-    "''",
-    "'From: aperson@example.org'",
-    "'Subject: \\xe4\\xb8\\x80\\xe7\\x95\\xaa'",
-    "'To: test@example.com'",
-    "'Content-Type: text/plain; charset=iso-2022-jp'",
-    "''",
-    "'\\xe4\\xb8\\x80\\xe7\\x95\\xaa'",
-    "''",
-    "'------------------------------'",
-    "''",
-    "'Subject: Pied de page des remises group\\xc3\\xa9es'",
-    "''",
-    "'_______________________________________________'",
-    "'Test mailing list'",
-    "'test@example.com'",
-    "'http://lists.example.com/listinfo/test@example.com'",
-    "''",
-    "''",
-    "'------------------------------'",
-    "''",
-    "'Fin de Groupe Test, Vol 1, Parution 2'",
-    "'*************************************'"]
-
-     >>> config.pop('french')
-
-
 Digest delivery
 ===============
 
@@ -538,12 +340,12 @@
 Only wperson and xperson get the MIME digests.
 
     >>> sorted(mime.msgdata['recipients'])
-    [u'wperson@example.com', u'xperson@example.com']
+    ['wperson@example.com', 'xperson@example.com']
 
 Only yperson and zperson get the RFC 1153 digests.
 
     >>> sorted(rfc1153.msgdata['recipients'])
-    [u'yperson@example.com', u'zperson@example.com']
+    ['yperson@example.com', 'zperson@example.com']
 
 Now uperson decides that they would like to start receiving digests too.
 ::
@@ -558,10 +360,10 @@
 
     >>> mime, rfc1153 = mime_rfc1153(messages)
     >>> sorted(mime.msgdata['recipients'])
-    [u'uperson@example.com', u'wperson@example.com', u'xperson@example.com']
+    ['uperson@example.com', 'wperson@example.com', 'xperson@example.com']
 
     >>> sorted(rfc1153.msgdata['recipients'])
-    [u'yperson@example.com', u'zperson@example.com']
+    ['yperson@example.com', 'zperson@example.com']
 
 At this point, both uperson and wperson decide that they'd rather receive
 regular deliveries instead of digests.  uperson would like to get any last
@@ -581,10 +383,10 @@
     >>> messages = get_queue_messages('virgin')
     >>> mime, rfc1153 = mime_rfc1153(messages)
     >>> sorted(mime.msgdata['recipients'])
-    [u'uperson@example.com', u'xperson@example.com']
+    ['uperson@example.com', 'xperson@example.com']
 
     >>> sorted(rfc1153.msgdata['recipients'])
-    [u'yperson@example.com', u'zperson@example.com']
+    ['yperson@example.com', 'zperson@example.com']
 
 Since uperson has received their last digest, they will not get any more of
 them.
@@ -599,7 +401,7 @@
 
     >>> mime, rfc1153 = mime_rfc1153(messages)
     >>> sorted(mime.msgdata['recipients'])
-    [u'xperson@example.com']
+    ['xperson@example.com']
 
     >>> sorted(rfc1153.msgdata['recipients'])
-    [u'yperson@example.com', u'zperson@example.com']
+    ['yperson@example.com', 'zperson@example.com']

=== modified file 'src/mailman/runners/docs/lmtp.rst'
--- src/mailman/runners/docs/lmtp.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/runners/docs/lmtp.rst	2014-12-22 17:38:14 +0000
@@ -20,7 +20,7 @@
 It also helps to have a nice LMTP client.
 
     >>> lmtp = helpers.get_lmtp_client()
-    (220, '... Python LMTP runner 1.0')
+    (220, b'... Python LMTP runner 1.0')
     >>> lmtp.lhlo('remote.example.org')
     (250, ...)
 
@@ -28,24 +28,8 @@
 Posting address
 ===============
 
-If the mail server tries to send a message to a nonexistent mailing list, it
-will get a 550 error.
-
-    >>> lmtp.sendmail(
-    ...     'anne.person@example.com',
-    ...     ['mylist@example.com'], """\
-    ... From: anne.person@example.com
-    ... To: mylist@example.com
-    ... Subject: An interesting message
-    ... Message-ID: <aardvark>
-    ...
-    ... This is an interesting message.
-    ... """)
-    Traceback (most recent call last):
-    ...
-    SMTPDataError: (550, 'Requested action not taken: mailbox unavailable')
-
-Once the mailing list is created, the posting address is valid.
+Once the mailing list is created, the posting address is valid, and messages
+can be sent to the list.
 ::
 
     >>> create_list('mylist@example.com')
@@ -82,7 +66,7 @@
     This is an interesting message.
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     to_list      : True
     version      : ...
@@ -92,24 +76,8 @@
 =============
 
 The LMTP server understands each of the list's sub-addreses, such as `-join`,
-`-leave`, `-request` and so on.  If the message is posted to an invalid
-sub-address though, it is rejected.
-
-    >>> lmtp.sendmail(
-    ...     'anne.person@example.com',
-    ...     ['mylist-bogus@example.com'], """\
-    ... From: anne.person@example.com
-    ... To: mylist-bogus@example.com
-    ... Subject: Help
-    ... Message-ID: <cow>
-    ...
-    ... Please help me.
-    ... """)
-    Traceback (most recent call last):
-    ...
-    SMTPDataError: (550, 'Requested action not taken: mailbox unavailable')
-
-But the message is accepted if posted to a valid sub-address.
+`-leave`, `-request` and so on.  The message is accepted if posted to a valid
+sub-address.
 
     >>> lmtp.sendmail(
     ...     'anne.person@example.com',
@@ -145,7 +113,7 @@
     Please help me.
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     subaddress   : request
     version      : ...
@@ -172,7 +140,7 @@
     1
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     subaddress   : bounces
     version      : ...
@@ -199,7 +167,7 @@
     1
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     subaddress   : confirm
     version      : ...
@@ -221,7 +189,7 @@
     1
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     subaddress   : join
     version      : ...
@@ -240,7 +208,7 @@
     1
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     subaddress   : join
     version      : ...
@@ -262,7 +230,7 @@
     1
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     subaddress   : leave
     version      : ...
@@ -281,7 +249,7 @@
     1
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     subaddress   : leave
     version      : ...
@@ -307,7 +275,7 @@
     >>> dump_msgdata(messages[0].msgdata)
     _parsemsg    : False
     envsender    : noreply@example.com
-    listname     : mylist@example.com
+    listid       : mylist.example.com
     original_size: ...
     subaddress   : owner
     to_owner     : True

=== modified file 'src/mailman/runners/docs/nntp.rst'
--- src/mailman/runners/docs/nntp.rst	2014-04-28 15:23:35 +0000
+++ src/mailman/runners/docs/nntp.rst	2014-12-22 17:38:14 +0000
@@ -37,7 +37,7 @@
 The message gets copied to the NNTP queue for preparation and posting.
 
     >>> filebase = config.switchboards['nntp'].enqueue(
-    ...     msg, listname='test@example.com')
+    ...     msg, listid='test.example.com')
     >>> from mailman.testing.helpers import make_testable_runner
     >>> from mailman.runners.nntp import NNTPRunner
     >>> runner = make_testable_runner(NNTPRunner, 'nntp')

=== modified file 'src/mailman/runners/docs/outgoing.rst'
--- src/mailman/runners/docs/outgoing.rst	2014-11-08 00:31:21 +0000
+++ src/mailman/runners/docs/outgoing.rst	2014-12-22 17:38:14 +0000
@@ -57,7 +57,7 @@
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
     ...     tolist=True,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
 
 Running the outgoing runner processes the message, delivering it to the
 upstream SMTP.
@@ -105,7 +105,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -147,7 +147,7 @@
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
     ...     verp=True,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -174,7 +174,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -215,7 +215,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -235,7 +235,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -254,7 +254,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -274,7 +274,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -308,7 +308,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -328,7 +328,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -348,7 +348,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -387,7 +387,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)
@@ -402,7 +402,7 @@
 
     >>> ignore = outgoing_queue.enqueue(
     ...     msg, msgdata,
-    ...     listname=mlist.fqdn_listname)
+    ...     listid=mlist.list_id)
     >>> outgoing.run()
     >>> messages = list(smtpd.messages)
     >>> len(messages)

=== modified file 'src/mailman/runners/lmtp.py'
--- src/mailman/runners/lmtp.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/lmtp.py	2014-12-22 17:38:14 +0000
@@ -91,15 +91,15 @@
     )
 
 DASH    = '-'
-CRLF    = b'\r\n'
-ERR_451 = b'451 Requested action aborted: error in processing'
-ERR_501 = b'501 Message has defects'
-ERR_502 = b'502 Error: command HELO not implemented'
-ERR_550 = b'550 Requested action not taken: mailbox unavailable'
-ERR_550_MID = b'550 No Message-ID header provided'
+CRLF    = '\r\n'
+ERR_451 = '451 Requested action aborted: error in processing'
+ERR_501 = '501 Message has defects'
+ERR_502 = '502 Error: command HELO not implemented'
+ERR_550 = '550 Requested action not taken: mailbox unavailable'
+ERR_550_MID = '550 No Message-ID header provided'
 
 # XXX Blech
-smtpd.__version__ = b'Python LMTP runner 1.0'
+smtpd.__version__ = 'Python LMTP runner 1.0'
 
 
 
@@ -147,6 +147,10 @@
         """HELO is not a valid LMTP command."""
         self.push(ERR_502)
 
+    ## def push(self, arg):
+    ##     import pdb; pdb.set_trace()
+    ##     return super().push(arg)
+
 
 
 class LMTPRunner(Runner, smtpd.SMTPServer):
@@ -202,18 +206,19 @@
         for to in rcpttos:
             try:
                 to = parseaddr(to)[1].lower()
-                listname, subaddress, domain = split_recipient(to)
+                local, subaddress, domain = split_recipient(to)
                 slog.debug('%s to: %s, list: %s, sub: %s, dom: %s',
-                           message_id, to, listname, subaddress, domain)
-                listname += '@' + domain
+                           message_id, to, local, subaddress, domain)
+                listname = '{}@{}'.format(local, domain)
                 if listname not in listnames:
                     status.append(ERR_550)
                     continue
+                listid = '{}.{}'.format(local, domain)
                 # The recipient is a valid mailing list.  Find the subaddress
                 # if there is one, and set things up to enqueue to the proper
                 # queue.
                 queue = None
-                msgdata = dict(listname=listname,
+                msgdata = dict(listid=listid,
                                original_size=msg.original_size,
                                received_time=received_time)
                 canonical_subaddress = SUBADDRESS_NAMES.get(subaddress)
@@ -243,7 +248,7 @@
                     config.switchboards[queue].enqueue(msg, msgdata)
                     slog.debug('%s subaddress: %s, queue: %s',
                                message_id, canonical_subaddress, queue)
-                    status.append(b'250 Ok')
+                    status.append('250 Ok')
             except Exception:
                 slog.exception('Queue detection: %s', msg['message-id'])
                 config.db.abort()

=== modified file 'src/mailman/runners/nntp.py'
--- src/mailman/runners/nntp.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/nntp.py	2014-12-22 17:38:14 +0000
@@ -31,11 +31,11 @@
 import logging
 import nntplib
 
-from cStringIO import StringIO
-
 from mailman.config import config
 from mailman.core.runner import Runner
 from mailman.interfaces.nntp import NewsgroupModeration
+from six.moves import cStringIO as StringIO
+
 
 COMMA = ','
 COMMASPACE = ', '
@@ -82,7 +82,7 @@
                                 user=config.nntp.user,
                                 password=config.nntp.password)
             conn.post(fp)
-        except nntplib.error_temp:
+        except nntplib.NNTPTemporaryError:
             log.exception('{0} NNTP error for {1}'.format(
                 msg.get('message-id', 'n/a'), mlist.fqdn_listname))
         except socket.error:
@@ -111,9 +111,9 @@
         del msg['approved']
         msg['Approved'] = mlist.posting_address
     # Should we restore the original, non-prefixed subject for gatewayed
-    # messages? TK: We use stripped_subject (prefix stripped) which was
-    # crafted in CookHeaders.py to ensure prefix was stripped from the subject
-    # came from mailing list user.
+    # messages? TK: We use stripped_subject (prefix stripped) which was crafted
+    # in the subject-prefix handler to ensure prefix was stripped from the
+    # subject came from mailing list user.
     stripped_subject = msgdata.get('stripped_subject',
                                    msgdata.get('original_subject'))
     if not mlist.nntp_prefix_subject_too and stripped_subject is not None:

=== modified file 'src/mailman/runners/tests/test_archiver.py'
--- src/mailman/runners/tests/test_archiver.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/tests/test_archiver.py	2014-12-22 17:38:14 +0000
@@ -110,7 +110,7 @@
         # Ensure that the archive runner ends up archiving the message.
         self._archiveq.enqueue(
             self._msg, {},
-            listname=self._mlist.fqdn_listname,
+            listid=self._mlist.list_id,
             received_time=now())
         self._runner.run()
         # There should now be a copy of the message in the file system.
@@ -126,7 +126,7 @@
         self._msg['Date'] = now(strip_tzinfo=False).strftime(RFC822_DATE_FMT)
         self._archiveq.enqueue(
             self._msg, {},
-            listname=self._mlist.fqdn_listname,
+            listid=self._mlist.list_id,
             received_time=now())
         self._runner.run()
         # There should now be a copy of the message in the file system.
@@ -144,7 +144,7 @@
         self._msg['Date'] = now(strip_tzinfo=False).strftime(RFC822_DATE_FMT)
         self._archiveq.enqueue(
             self._msg, {},
-            listname=self._mlist.fqdn_listname,
+            listid=self._mlist.list_id,
             received_time=now())
         self._runner.run()
         # There should now be a copy of the message in the file system.
@@ -163,7 +163,7 @@
         # again), fast forward a few days.
         self._archiveq.enqueue(
             self._msg, {},
-            listname=self._mlist.fqdn_listname,
+            listid=self._mlist.list_id,
             received_time=now(strip_tzinfo=False))
         self._runner.run()
         # There should now be a copy of the message in the file system.
@@ -182,7 +182,7 @@
         # again as will happen in the runner), fast forward a few days.
         self._archiveq.enqueue(
             self._msg, {},
-            listname=self._mlist.fqdn_listname)
+            listid=self._mlist.list_id)
         factory.fast_forward(days=4)
         self._runner.run()
         # There should now be a copy of the message in the file system.
@@ -205,7 +205,7 @@
         # again as will happen in the runner), fast forward a few days.
         self._archiveq.enqueue(
             self._msg, {},
-            listname=self._mlist.fqdn_listname)
+            listid=self._mlist.list_id)
         factory.fast_forward(days=4)
         self._runner.run()
         # There should now be a copy of the message in the file system.
@@ -228,7 +228,7 @@
         # again as will happen in the runner), fast forward a few days.
         self._archiveq.enqueue(
             self._msg, {},
-            listname=self._mlist.fqdn_listname)
+            listid=self._mlist.list_id)
         factory.fast_forward(days=4)
         self._runner.run()
         # There should now be a copy of the message in the file system.
@@ -249,6 +249,6 @@
         config.db.store.commit()
         self._archiveq.enqueue(
             self._msg, {},
-            listname=self._mlist.fqdn_listname)
+            listid=self._mlist.list_id)
         self._runner.run()
         self.assertEqual(os.listdir(config.MESSAGES_DIR), [])

=== modified file 'src/mailman/runners/tests/test_bounce.py'
--- src/mailman/runners/tests/test_bounce.py	2014-01-07 03:43:59 +0000
+++ src/mailman/runners/tests/test_bounce.py	2014-12-22 17:38:14 +0000
@@ -69,7 +69,7 @@
 Message-Id: <first>
 
 """)
-        self._msgdata = dict(listname='test@example.com')
+        self._msgdata = dict(listid='test.example.com')
         self._processor = getUtility(IBounceProcessor)
         config.push('site owner', """
         [mailman]
@@ -284,7 +284,7 @@
 Message-Id: <first>
 
 """)
-        self._bounceq.enqueue(bounce, dict(listname='test@example.com'))
+        self._bounceq.enqueue(bounce, dict(listid='test.example.com'))
         self.assertEqual(len(self._bounceq.files), 1)
         self._runner.run()
         self.assertEqual(len(get_queue_messages('bounces')), 0)

=== modified file 'src/mailman/runners/tests/test_confirm.py'
--- src/mailman/runners/tests/test_confirm.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/tests/test_confirm.py	2014-12-22 17:38:14 +0000
@@ -68,7 +68,7 @@
 
 """)
         msg['Subject'] = subject
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         # Anne is now a confirmed member so her user record and email address
         # should exist in the database.
@@ -88,7 +88,7 @@
 
 """)
         msg['Subject'] = subject
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         # Anne is now a confirmed member so her user record and email address
         # should exist in the database.
@@ -144,7 +144,7 @@
 """)
         msg['Subject'] = subject
         msg['To'] = to
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         # Anne is now a confirmed member so her user record and email address
         # should exist in the database.
@@ -177,7 +177,7 @@
 """)
         msg['Subject'] = subject
         msg['To'] = to
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         # Anne is now a confirmed member so her user record and email address
         # should exist in the database.
@@ -208,7 +208,7 @@
 """)
         msg['Subject'] = subject
         msg['To'] = to
-        self._commandq.enqueue(msg, dict(listname='test@example.com',
+        self._commandq.enqueue(msg, dict(listid='test.example.com',
                                          subaddress='confirm'))
         self._runner.run()
         # Anne is now a confirmed member so her user record and email address
@@ -223,7 +223,7 @@
         # one 'Confirmation email' line.
         confirmation_lines = []
         in_results = False
-        for line in body_line_iterator(messages[0].msg, decode=True):
+        for line in body_line_iterator(messages[0].msg):
             line = line.strip()
             if in_results:
                 if line.startswith('- Done'):
@@ -253,7 +253,7 @@
 """)
         msg['Subject'] = subject
         msg['To'] = to
-        self._commandq.enqueue(msg, dict(listname='test@example.com',
+        self._commandq.enqueue(msg, dict(listid='test.example.com',
                                          subaddress='confirm'))
         self._runner.run()
         # Now there's a email command notification and a welcome message.  All

=== modified file 'src/mailman/runners/tests/test_digest.py'
--- src/mailman/runners/tests/test_digest.py	2014-12-11 02:49:39 +0000
+++ src/mailman/runners/tests/test_digest.py	2014-12-22 17:38:14 +0000
@@ -22,6 +22,7 @@
 __metaclass__ = type
 __all__ = [
     'TestDigest',
+    'TestI18nDigest',
     ]
 
 
@@ -70,8 +71,35 @@
                              'Test Digest, Vol 1, Issue 1')
 
     def test_simple_message(self):
+<<<<<<< TREE
         make_digest_messages(self._mlist)
         self._check_virgin_queue()
+=======
+        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.
+        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')
+>>>>>>> MERGE-SOURCE
 
     def test_non_ascii_message(self):
         msg = Message()
@@ -82,11 +110,20 @@
                             'plain', 'utf-8'))
         mbox = digest_mbox(self._mlist)
         mbox.add(msg.as_string())
+<<<<<<< TREE
+=======
+        self._digestq.enqueue(
+            msg,
+            listid=self._mlist.list_id,
+            digest_path=mbox_path,
+            volume=1, digest_number=1)
+>>>>>>> MERGE-SOURCE
         # Use any error logs as the error message if the test fails.
         error_log = LogFileMark('mailman.error')
         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())
+<<<<<<< TREE
         self._check_virgin_queue()
 
     def test_mime_digest_format(self):
@@ -140,3 +177,89 @@
         text/plain
     text/plain
 """)
+=======
+        # 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')
+
+
+
+class TestI18nDigest(unittest.TestCase):
+    layer = ConfigLayer
+    maxDiff = None
+
+    def setUp(self):
+        config.push('french', """
+        [mailman]
+        default_language: fr
+        """)
+        self.addCleanup(config.pop, 'french')
+        self._mlist = create_list('test@example.com')
+        self._mlist.preferred_language = 'fr'
+        self._mlist.digest_size_threshold = 0
+        self._process = config.handlers['to-digest'].process
+        self._runner = make_testable_runner(DigestRunner)
+
+    def test_multilingual_digest(self):
+        # When messages come in with a content-type character set different
+        # than that of the list's preferred language, recipients will get an
+        # internationalized digest.
+        msg = mfs("""\
+From: aperson@example.org
+To: test@example.com
+Subject: =?iso-2022-jp?b?GyRCMGxIVhsoQg==?=
+MIME-Version: 1.0
+Content-Type: text/plain; charset=iso-2022-jp
+Content-Transfer-Encoding: 7bit
+
+\x1b$B0lHV\x1b(B
+""")
+        self._process(self._mlist, msg, {})
+        self._runner.run()
+        # There are two digests in the virgin queue; one is the MIME digest
+        # and the other is the RFC 1153 digest.
+        messages = get_queue_messages('virgin')
+        self.assertEqual(len(messages), 2)
+        if messages[0].msg.is_multipart():
+            mime, rfc1153 = messages[0].msg, messages[1].msg
+        else:
+            rfc1153, mime = messages[0].msg, messages[1].msg
+        # The MIME version contains a mix of French and Japanese.  The digest
+        # chrome added by Mailman is in French.
+        self.assertEqual(mime['subject'].encode(),
+                         '=?iso-8859-1?q?Groupe_Test=2C_Vol_1=2C_Parution_1?=')
+        self.assertEqual(str(mime['subject']),
+                         'Groupe Test, Vol 1, Parution 1')
+        # The first subpart contains the iso-8859-1 masthead.
+        masthead = mime.get_payload(0).get_payload(decode=True).decode(
+            'iso-8859-1')
+        self.assertMultiLineEqual(masthead.splitlines()[0],
+                                  'Envoyez vos messages pour la liste Test à')
+        # The second subpart contains the utf-8 table of contents.
+        self.assertEqual(mime.get_payload(1)['content-description'],
+                         "Today's Topics (1 messages)")
+        toc = mime.get_payload(1).get_payload(decode=True).decode('utf-8')
+        self.assertMultiLineEqual(toc.splitlines()[0], 'Thèmes du jour :')
+        # The third subpart contains the posted message in Japanese.
+        self.assertEqual(mime.get_payload(2).get_content_type(),
+                         'message/rfc822')
+        post = mime.get_payload(2).get_payload(0)
+        self.assertEqual(post['subject'], '=?iso-2022-jp?b?GyRCMGxIVhsoQg==?=')
+        # Compare the bytes so that this module doesn't contain string
+        # literals in multiple incompatible character sets.
+        self.assertEqual(post.get_payload(decode=True), b'\x1b$B0lHV\x1b(B\n')
+        # The RFC 1153 digest will have the same subject, but its payload will
+        # be recast into utf-8.
+        self.assertEqual(str(rfc1153['subject']),
+                         'Groupe Test, Vol 1, Parution 1')
+        self.assertEqual(rfc1153.get_charset(), 'utf-8')
+        lines = rfc1153.get_payload(decode=True).decode('utf-8').splitlines()
+        self.assertEqual(lines[0], 'Envoyez vos messages pour la liste Test à')
+>>>>>>> MERGE-SOURCE

=== modified file 'src/mailman/runners/tests/test_incoming.py'
--- src/mailman/runners/tests/test_incoming.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/tests/test_incoming.py	2014-12-22 17:38:14 +0000
@@ -76,7 +76,7 @@
 
     def test_posting(self):
         # A message posted to the list goes through the posting chain.
-        msgdata = dict(listname='test@example.com')
+        msgdata = dict(listid='test.example.com')
         config.switchboards['in'].enqueue(self._msg, msgdata)
         self._in.run()
         messages = get_queue_messages('out')
@@ -85,7 +85,7 @@
 
     def test_owner(self):
         # A message posted to the list goes through the posting chain.
-        msgdata = dict(listname='test@example.com',
+        msgdata = dict(listid='test.example.com',
                        to_owner=True)
         config.switchboards['in'].enqueue(self._msg, msgdata)
         self._in.run()

=== modified file 'src/mailman/runners/tests/test_join.py'
--- src/mailman/runners/tests/test_join.py	2014-01-07 03:43:59 +0000
+++ src/mailman/runners/tests/test_join.py	2014-12-22 17:38:14 +0000
@@ -72,7 +72,7 @@
         # Adding the subaddress to the metadata dictionary mimics what happens
         # when the above email message is first processed by the lmtp runner.
         # For convenience, we skip that step in this test.
-        self._commandq.enqueue(msg, dict(listname='test@example.com',
+        self._commandq.enqueue(msg, dict(listid='test.example.com',
                                          subaddress='join'))
         self._runner.run()
         # There will be two messages in the queue.  The first one is a reply
@@ -87,7 +87,7 @@
         # one 'Confirmation email' line.
         confirmation_lines = []
         in_results = False
-        for line in body_line_iterator(messages[0].msg, decode=True):
+        for line in body_line_iterator(messages[0].msg):
             line = line.strip()
             if in_results:
                 if line.startswith('- Done'):
@@ -112,7 +112,7 @@
 Subject: join
 
 """)
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         # There will be one message in the queue - a reply to Anne notifying
         # her of the status of her command email.  Because Anne is already
@@ -125,7 +125,7 @@
         # one 'Confirmation email' line.
         confirmation_lines = []
         in_results = False
-        for line in body_line_iterator(messages[0].msg, decode=True):
+        for line in body_line_iterator(messages[0].msg):
             line = line.strip()
             if in_results:
                 if line.startswith('- Done'):
@@ -181,7 +181,7 @@
 
 join
 """)
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         anne = self._confirm()
         self.assertEqual(anne.address.email, 'anne@example.org')
@@ -195,7 +195,7 @@
 
 join digest=no
 """)
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         anne = self._confirm()
         self.assertEqual(anne.address.email, 'anne@example.org')
@@ -209,7 +209,7 @@
 
 join digest=mime
 """)
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         anne = self._confirm()
         self.assertEqual(anne.address.email, 'anne@example.org')
@@ -223,7 +223,7 @@
 
 join digest=plain
 """)
-        self._commandq.enqueue(msg, dict(listname='test@example.com'))
+        self._commandq.enqueue(msg, dict(listid='test.example.com'))
         self._runner.run()
         anne = self._confirm()
         self.assertEqual(anne.address.email, 'anne@example.org')

=== modified file 'src/mailman/runners/tests/test_lmtp.py'
--- src/mailman/runners/tests/test_lmtp.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/tests/test_lmtp.py	2014-12-22 17:38:14 +0000
@@ -30,7 +30,6 @@
 import unittest
 
 from datetime import datetime
-
 from mailman.config import config
 from mailman.app.lifecycle import create_list
 from mailman.database.transaction import transaction
@@ -67,7 +66,7 @@
         # reasons)
         self.assertEqual(cm.exception.smtp_code, 550)
         self.assertEqual(cm.exception.smtp_error,
-                         'No Message-ID header provided')
+                         b'No Message-ID header provided')
 
     def test_message_id_hash_is_added(self):
         self._lmtp.sendmail('anne@example.com', ['test@example.com'], """\
@@ -118,6 +117,36 @@
         queue_directory = os.path.join(config.QUEUE_DIR, 'lmtp')
         self.assertFalse(os.path.isdir(queue_directory))
 
+    def test_nonexistent_mailing_list(self):
+        # Trying to post to a nonexistent mailing list is an error.
+        with self.assertRaises(smtplib.SMTPDataError) as cm:
+            self._lmtp.sendmail('anne@example.com',
+                                ['notalist@example.com'], """\
+From: anne.person@example.com
+To: notalist@example.com
+Subject: An interesting message
+Message-ID: <aardvark>
+
+""")
+        self.assertEqual(cm.exception.smtp_code, 550)
+        self.assertEqual(cm.exception.smtp_error,
+                         b'Requested action not taken: mailbox unavailable')
+
+    def test_missing_subaddress(self):
+        # Trying to send a message to a bogus subaddress is an error.
+        with self.assertRaises(smtplib.SMTPDataError) as cm:
+            self._lmtp.sendmail('anne@example.com',
+                                ['test-bogus@example.com'], """\
+From: anne.person@example.com
+To: test-bogus@example.com
+Subject: An interesting message
+Message-ID: <aardvark>
+
+""")
+        self.assertEqual(cm.exception.smtp_code, 550)
+        self.assertEqual(cm.exception.smtp_error,
+                         b'Requested action not taken: mailbox unavailable')
+
 
 
 class TestBugs(unittest.TestCase):
@@ -142,5 +171,5 @@
 """)
         messages = get_queue_messages('in')
         self.assertEqual(len(messages), 1)
-        self.assertEqual(messages[0].msgdata['listname'],
-                         'my-list@example.com')
+        self.assertEqual(messages[0].msgdata['listid'],
+                         'my-list.example.com')

=== modified file 'src/mailman/runners/tests/test_nntp.py'
--- src/mailman/runners/tests/test_nntp.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/tests/test_nntp.py	2014-12-22 17:38:14 +0000
@@ -257,7 +257,7 @@
     @mock.patch('nntplib.NNTP')
     def test_connect(self, class_mock):
         # Test connection to the NNTP server with default values.
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         class_mock.assert_called_once_with(
             '', 119, user='', password='', readermode=True)
@@ -267,7 +267,7 @@
     @mock.patch('nntplib.NNTP')
     def test_connect_with_configuration(self, class_mock):
         # Test connection to the NNTP server with specific values.
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         class_mock.assert_called_once_with(
             'nntp.example.com', 2112,
@@ -276,7 +276,7 @@
     @mock.patch('nntplib.NNTP')
     def test_post(self, class_mock):
         # Test that the message is posted to the NNTP server.
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         # Get the mocked instance, which was used in the runner.
         conn_mock = class_mock()
@@ -295,7 +295,7 @@
     def test_connection_got_quit(self, class_mock):
         # The NNTP connection gets closed after a successful post.
         # Test that the message is posted to the NNTP server.
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         # Get the mocked instance, which was used in the runner.
         conn_mock = class_mock()
@@ -304,18 +304,19 @@
         # and make some simple checks that the message is what we expected.
         conn_mock.quit.assert_called_once_with()
 
-    @mock.patch('nntplib.NNTP', side_effect=nntplib.error_temp)
+    @mock.patch('nntplib.NNTP', side_effect=nntplib.NNTPTemporaryError)
     def test_connect_with_nntplib_failure(self, class_mock):
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         mark = LogFileMark('mailman.error')
         self._runner.run()
         log_message = mark.readline()[:-1]
-        self.assertTrue(log_message.endswith(
-            'NNTP error for test@example.com'))
+        self.assertTrue(
+            log_message.endswith('NNTP error for test@example.com'),
+            log_message)
 
     @mock.patch('nntplib.NNTP', side_effect=socket.error)
     def test_connect_with_socket_failure(self, class_mock):
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         mark = LogFileMark('mailman.error')
         self._runner.run()
         log_message = mark.readline()[:-1]
@@ -330,7 +331,7 @@
             # I.e. stop immediately, since the queue will not be empty.
             return True
         runner = make_testable_runner(nntp.NNTPRunner, 'nntp', predicate=once)
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         mark = LogFileMark('mailman.error')
         runner.run()
         log_message = mark.readline()[:-1]
@@ -338,14 +339,14 @@
             'NNTP unexpected exception for test@example.com'))
         messages = get_queue_messages('nntp')
         self.assertEqual(len(messages), 1)
-        self.assertEqual(messages[0].msgdata['listname'], 'test@example.com')
+        self.assertEqual(messages[0].msgdata['listid'], 'test.example.com')
         self.assertEqual(messages[0].msg['subject'], 'A newsgroup posting')
 
-    @mock.patch('nntplib.NNTP', side_effect=nntplib.error_temp)
+    @mock.patch('nntplib.NNTP', side_effect=nntplib.NNTPTemporaryError)
     def test_connection_never_gets_quit_after_failures(self, class_mock):
         # The NNTP connection doesn't get closed after a unsuccessful
         # connection, since there's nothing to close.
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         # Get the mocked instance, which was used in the runner.  Turn off the
         # exception raising side effect first though!
@@ -361,8 +362,8 @@
         # The NNTP connection does get closed after a unsuccessful post.
         # Add a side-effect to the instance mock's .post() method.
         conn_mock = class_mock()
-        conn_mock.post.side_effect = nntplib.error_temp
-        self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
+        conn_mock.post.side_effect = nntplib.NNTPTemporaryError
+        self._nntpq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         # The connection object's post() method was called once with a
         # file-like object containing the message's bytes.  Read those bytes

=== modified file 'src/mailman/runners/tests/test_outgoing.py'
--- src/mailman/runners/tests/test_outgoing.py	2014-04-28 15:23:35 +0000
+++ src/mailman/runners/tests/test_outgoing.py	2014-12-22 17:38:14 +0000
@@ -96,7 +96,7 @@
         deliver_after = now() + timedelta(days=10)
         self._msgdata['deliver_after'] = deliver_after
         self._outq.enqueue(self._msg, self._msgdata,
-                           tolist=True, listname='test@example.com')
+                           tolist=True, listid='test.example.com')
         self._runner.run()
         items = get_queue_messages('out')
         self.assertEqual(len(items), 1)
@@ -149,20 +149,20 @@
 
     def test_delivery_callback(self):
         # Test that the configuration variable calls the appropriate callback.
-        self._outq.enqueue(self._msg, {}, listname='test@example.com')
+        self._outq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         self.assertEqual(captured_mlist, self._mlist)
         self.assertEqual(captured_msg.as_string(), self._msg.as_string())
         # Of course, the message metadata will contain a bunch of keys added
         # by the processing.  We don't really care about the details, so this
         # test is a good enough stand-in.
-        self.assertEqual(captured_msgdata['listname'], 'test@example.com')
+        self.assertEqual(captured_msgdata['listid'], 'test.example.com')
 
     def test_verp_in_metadata(self):
         # Test that if the metadata has a 'verp' key, it is unchanged.
         marker = 'yepper'
         msgdata = dict(verp=marker)
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         self._runner.run()
         self.assertEqual(captured_msgdata['verp'], marker)
 
@@ -171,7 +171,7 @@
         # indicates, messages will be VERP'd.
         msgdata = {}
         self._mlist.personalize = Personalization.individual
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         with temporary_config('personalize', """
         [mta]
         verp_personalized_deliveries: yes
@@ -184,7 +184,7 @@
         # indicates, messages will be VERP'd.
         msgdata = {}
         self._mlist.personalize = Personalization.full
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         with temporary_config('personalize', """
         [mta]
         verp_personalized_deliveries: yes
@@ -197,14 +197,14 @@
         # does not indicate, messages will not be VERP'd.
         msgdata = {}
         self._mlist.personalize = Personalization.full
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         self._runner.run()
         self.assertFalse('verp' in captured_msgdata)
 
     def test_verp_never(self):
         # Never VERP when the interval is zero.
         msgdata = {}
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         with temporary_config('personalize', """
         [mta]
         verp_delivery_interval: 0
@@ -215,7 +215,7 @@
     def test_verp_always(self):
         # Always VERP when the interval is one.
         msgdata = {}
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         with temporary_config('personalize', """
         [mta]
         verp_delivery_interval: 1
@@ -227,7 +227,7 @@
         # VERP every so often, when the post_id matches.
         self._mlist.post_id = 5
         msgdata = {}
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         with temporary_config('personalize', """
         [mta]
         verp_delivery_interval: 5
@@ -239,7 +239,7 @@
         # VERP every so often, when the post_id matches.
         self._mlist.post_id = 4
         msgdata = {}
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         with temporary_config('personalize', """
             [mta]
             verp_delivery_interval: 5
@@ -287,7 +287,7 @@
         error_log = logging.getLogger('mailman.error')
         filename = error_log.handlers[0].filename
         filepos = os.stat(filename).st_size
-        self._outq.enqueue(self._msg, {}, listname='test@example.com')
+        self._outq.enqueue(self._msg, {}, listid='test.example.com')
         with temporary_config('port 0', """
             [mta]
             smtp_port: 0
@@ -308,7 +308,7 @@
         # that is a log message.  Start by opening the error log and reading
         # the current file position.
         mark = LogFileMark('mailman.error')
-        self._outq.enqueue(self._msg, {}, listname='test@example.com')
+        self._outq.enqueue(self._msg, {}, listid='test.example.com')
         with temporary_config('port 0', """
             [mta]
             smtp_port: 2112
@@ -369,7 +369,7 @@
         token = send_probe(member, self._msg)
         msgdata = dict(probe_token=token)
         permanent_failures.append('anne@example.com')
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         self._runner.run()
         events = list(self._processor.unprocessed)
         self.assertEqual(len(events), 1)
@@ -390,7 +390,7 @@
         getUtility(IPendings).confirm(token)
         msgdata = dict(probe_token=token)
         permanent_failures.append('anne@example.com')
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         self._runner.run()
         events = list(self._processor.unprocessed)
         self.assertEqual(len(events), 0)
@@ -404,7 +404,7 @@
         getUtility(IPendings).confirm(token)
         msgdata = dict(probe_token=token)
         temporary_failures.append('anne@example.com')
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         self._runner.run()
         events = list(self._processor.unprocessed)
         self.assertEqual(len(events), 0)
@@ -412,7 +412,7 @@
     def test_one_permanent_failure(self):
         # Normal (i.e. non-probe) permanent failures just get registered.
         permanent_failures.append('anne@example.com')
-        self._outq.enqueue(self._msg, {}, listname='test@example.com')
+        self._outq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         events = list(self._processor.unprocessed)
         self.assertEqual(len(events), 1)
@@ -423,7 +423,7 @@
         # Two normal (i.e. non-probe) permanent failures just get registered.
         permanent_failures.append('anne@example.com')
         permanent_failures.append('bart@example.com')
-        self._outq.enqueue(self._msg, {}, listname='test@example.com')
+        self._outq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         events = list(self._processor.unprocessed)
         self.assertEqual(len(events), 2)
@@ -437,7 +437,7 @@
         # put in the retry queue, but with some metadata to prevent infinite
         # retries.
         temporary_failures.append('cris@example.com')
-        self._outq.enqueue(self._msg, {}, listname='test@example.com')
+        self._outq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         events = list(self._processor.unprocessed)
         self.assertEqual(len(events), 0)
@@ -458,7 +458,7 @@
         # retries.
         temporary_failures.append('cris@example.com')
         temporary_failures.append('dave@example.com')
-        self._outq.enqueue(self._msg, {}, listname='test@example.com')
+        self._outq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         events = list(self._processor.unprocessed)
         self.assertEqual(len(events), 0)
@@ -476,7 +476,7 @@
         permanent_failures.append('fred@example.com')
         temporary_failures.append('gwen@example.com')
         temporary_failures.append('herb@example.com')
-        self._outq.enqueue(self._msg, {}, listname='test@example.com')
+        self._outq.enqueue(self._msg, {}, listid='test.example.com')
         self._runner.run()
         # Let's look at the permanent failures.
         events = list(self._processor.unprocessed)
@@ -503,7 +503,7 @@
                          as_timedelta(config.mta.delivery_retry_period))
         msgdata = dict(last_recip_count=2,
                        deliver_until=deliver_until)
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         self._runner.run()
         # The retry queue should have our message waiting to be retried.
         items = get_queue_messages('retry')
@@ -522,7 +522,7 @@
         deliver_until = datetime(2005, 8, 1, 7, 49, 23) + retry_period
         msgdata = dict(last_recip_count=2,
                        deliver_until=deliver_until)
-        self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+        self._outq.enqueue(self._msg, msgdata, listid='test.example.com')
         # Before the runner runs, several days pass.
         factory.fast_forward(retry_period.days + 1)
         mark = LogFileMark('mailman.smtp')

=== modified file 'src/mailman/runners/tests/test_owner.py'
--- src/mailman/runners/tests/test_owner.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/tests/test_owner.py	2014-12-22 17:38:14 +0000
@@ -89,7 +89,7 @@
         # get a copy of the message.
         lmtp = get_lmtp_client(quiet=True)
         lmtp.lhlo('remote.example.org')
-        lmtp.sendmail('zuzu@example.org', ['test-owner@example.com'], """\
+        lmtp.sendmail('zuzu@example.org', ['test-owner@example.com'], b"""\
 From: Zuzu Person <zuzu@example.org>
 To: test-owner@example.com
 Message-ID: <ant>

=== modified file 'src/mailman/runners/tests/test_pipeline.py'
--- src/mailman/runners/tests/test_pipeline.py	2014-01-01 14:59:42 +0000
+++ src/mailman/runners/tests/test_pipeline.py	2014-12-22 17:38:14 +0000
@@ -101,7 +101,7 @@
     def test_posting(self):
         # A message accepted for posting gets processed through the posting
         # pipeline.
-        msgdata = dict(listname='test@example.com')
+        msgdata = dict(listid='test.example.com')
         config.switchboards['pipeline'].enqueue(self._msg, msgdata)
         self._pipeline.run()
         self.assertEqual(len(self._markers), 1)
@@ -110,7 +110,7 @@
     def test_owner(self):
         # A message accepted for posting to a list's owners gets processed
         # through the owner pipeline.
-        msgdata = dict(listname='test@example.com',
+        msgdata = dict(listid='test.example.com',
                        to_owner=True)
         config.switchboards['pipeline'].enqueue(self._msg, msgdata)
         self._pipeline.run()

=== modified file 'src/mailman/runners/tests/test_retry.py'
--- src/mailman/runners/tests/test_retry.py	2014-04-28 15:23:35 +0000
+++ src/mailman/runners/tests/test_retry.py	2014-12-22 17:38:14 +0000
@@ -54,7 +54,7 @@
 Message-Id: <first>
 
 """)
-        self._msgdata = dict(listname='test@example.com')
+        self._msgdata = dict(listid='test.example.com')
 
     def test_message_put_in_outgoing_queue(self):
         self._retryq.enqueue(self._msg, self._msgdata)

=== modified file 'src/mailman/testing/helpers.py'
--- src/mailman/testing/helpers.py	2014-12-11 02:59:13 +0000
+++ src/mailman/testing/helpers.py	2014-12-22 17:38:14 +0000
@@ -22,6 +22,7 @@
 __metaclass__ = type
 __all__ = [
     'LogFileMark',
+    'PrettyEmailPolicy',
     'TestableMaster',
     'call_api',
     'chdir',
@@ -60,11 +61,13 @@
 from email import message_from_string
 from httplib2 import Http
 from lazr.config import as_timedelta
-from urllib import urlencode
-from urllib2 import HTTPError
+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
@@ -335,7 +338,10 @@
     basic_auth = '{0}:{1}'.format(
         (config.webservice.admin_user if username is None else username),
         (config.webservice.admin_pass if password is None else password))
-    headers['Authorization'] = 'Basic ' + b64encode(basic_auth)
+    # b64encode() requires a bytes, but the header value must be str.  Do the
+    # necessary conversion dances.
+    token = b64encode(basic_auth.encode('utf-8')).decode('ascii')
+    headers['Authorization'] = 'Basic ' + token
     response, content = Http().request(url, method, data, headers)
     # If we did not get a 2xx status code, make this look like a urllib2
     # exception, for backward compatibility with existing doctests.
@@ -470,10 +476,11 @@
     """
     # Reset the database between tests.
     config.db._reset()
-    # Remove any digest files.
+    # Remove any digest files and members.txt file (for the file-recips
+    # handler) in the lists' data directories.
     for dirpath, dirnames, filenames in os.walk(config.LIST_DATA_DIR):
         for filename in filenames:
-            if filename.endswith('.mmdf'):
+            if filename.endswith('.mmdf') or filename == 'members.txt':
                 os.remove(os.path.join(dirpath, filename))
     # Remove all residual queue files.
     for dirpath, dirnames, filenames in os.walk(config.QUEUE_DIR):
@@ -508,9 +515,8 @@
     """
     # This mimic what Switchboard.dequeue() does when parsing a message from
     # text into a Message instance.
-    text = unicode_text.encode('ascii')
-    original_size = len(text)
-    message = message_from_string(text, Message)
+    original_size = len(unicode_text)
+    message = message_from_string(unicode_text, Message)
     message.original_size = original_size
     return message
 
@@ -531,6 +537,7 @@
         with open(self._filename) as fp:
             fp.seek(self._filepos)
             return fp.read()
+<<<<<<< TREE
 
 
 
@@ -552,3 +559,27 @@
         volume=1, digest_number=1)
     runner = make_testable_runner(DigestRunner, 'digest')
     runner.run()
+=======
+
+
+
+def _pretty(self, *args, **kws):
+    return str(self)
+
+
+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)
+>>>>>>> MERGE-SOURCE

=== modified file 'src/mailman/testing/layers.py'
--- src/mailman/testing/layers.py	2014-11-01 17:46:36 +0000
+++ src/mailman/testing/layers.py	2014-12-22 17:38:14 +0000
@@ -46,7 +46,7 @@
 import tempfile
 
 from lazr.config import as_boolean
-from pkg_resources import resource_string
+from pkg_resources import resource_string as resource_bytes
 from textwrap import dedent
 from zope.component import getUtility
 
@@ -132,7 +132,8 @@
         configuration: {1}
         """.format(cls.var_dir, postfix_cfg))
         # Read the testing config and push it.
-        test_config += resource_string('mailman.testing', 'testing.cfg')
+        more = resource_bytes('mailman.testing', 'testing.cfg')
+        test_config += more.decode('utf-8')
         config.create_paths = True
         config.push('test config', test_config)
         # Initialize everything else.

=== modified file 'src/mailman/testing/mta.py'
--- src/mailman/testing/mta.py	2014-11-08 00:31:21 +0000
+++ src/mailman/testing/mta.py	2014-12-22 17:38:14 +0000
@@ -27,10 +27,9 @@
 
 import logging
 
-from Queue import Empty, Queue
-
 from lazr.smtptest.controller import QueueController
 from lazr.smtptest.server import Channel, QueueServer
+from six.moves.queue import Empty, Queue
 from zope.interface import implementer
 
 from mailman.interfaces.mta import IMailTransportAgentLifecycle
@@ -60,28 +59,28 @@
 
     def smtp_EHLO(self, arg):
         if not arg:
-            self.push(b'501 Syntax: HELO hostname')
+            self.push('501 Syntax: HELO hostname')
             return
         if self._SMTPChannel__greeting:
-            self.push(b'503 Duplicate HELO/EHLO')
+            self.push('503 Duplicate HELO/EHLO')
         else:
             self._SMTPChannel__greeting = arg
-            self.push(b'250-%s' % self._SMTPChannel__fqdn)
-            self.push(b'250 AUTH PLAIN')
+            self.push('250-%s' % self._SMTPChannel__fqdn)
+            self.push('250 AUTH PLAIN')
 
     def smtp_STAT(self, arg):
         """Cause the server to send statistics to its controller."""
         self._server.send_statistics()
-        self.push(b'250 Ok')
+        self.push('250 Ok')
 
     def smtp_AUTH(self, arg):
         """Record that the AUTH occurred."""
         if arg == 'PLAIN AHRlc3R1c2VyAHRlc3RwYXNz':
             # testuser:testpass
-            self.push(b'235 Ok')
+            self.push('235 Ok')
             self._server.send_auth(arg)
         else:
-            self.push(b'571 Bad authentication')
+            self.push('571 Bad authentication')
 
     def smtp_RCPT(self, arg):
         """For testing, sometimes cause a non-25x response."""
@@ -92,7 +91,7 @@
         else:
             # The test suite wants this to fail.  The message corresponds to
             # the exception we expect smtplib.SMTP to raise.
-            self.push(b'%d Error: SMTPRecipientsRefused' % code)
+            self.push('%d Error: SMTPRecipientsRefused' % code)
 
     def smtp_MAIL(self, arg):
         """For testing, sometimes cause a non-25x response."""
@@ -103,7 +102,7 @@
         else:
             # The test suite wants this to fail.  The message corresponds to
             # the exception we expect smtplib.SMTP to raise.
-            self.push(b'%d Error: SMTPResponseException' % code)
+            self.push('%d Error: SMTPResponseException' % code)
 
 
 
@@ -211,7 +210,7 @@
         :rtype: integer
         """
         smtpd = self._connect()
-        smtpd.docmd(b'STAT')
+        smtpd.docmd('STAT')
         # An Empty exception will occur if the data isn't available in 10
         # seconds.  Let that propagate.
         return self.oob_queue.get(block=True, timeout=10)
@@ -232,4 +231,4 @@
 
     def reset(self):
         smtpd = self._connect()
-        smtpd.docmd(b'RSET')
+        smtpd.docmd('RSET')

=== modified file 'src/mailman/testing/nose.py'
--- src/mailman/testing/nose.py	2014-01-01 14:59:42 +0000
+++ src/mailman/testing/nose.py	2014-12-22 17:38:14 +0000
@@ -116,3 +116,9 @@
         # Suppress the extra "Doctest: ..." line.
         test.shortDescription = lambda: None
         event.extraTests.append(test)
+
+    ## def startTest(self, event):
+    ##     import sys; print('vvvvv', event.test, file=sys.stderr)
+
+    ## def stopTest(self, event):
+    ##     import sys; print('^^^^^', event.test, file=sys.stderr)

=== modified file 'src/mailman/utilities/email.py'
--- src/mailman/utilities/email.py	2014-12-09 01:38:26 +0000
+++ src/mailman/utilities/email.py	2014-12-22 17:38:14 +0000
@@ -70,7 +70,10 @@
         message_id = message_id[1:-1]
     else:
         message_id = message_id.strip()
-    digest = sha1(message_id).digest()
+    # Because .digest() returns bytes, b32encode() will return bytes, however
+    # we need a string for the header value.  We know the b32encoded byte
+    # string must be ascii-only.
+    digest = sha1(message_id.encode('utf-8')).digest()
     message_id_hash = b32encode(digest)
     del msg['x-message-id-hash']
-    msg['X-Message-ID-Hash'] = message_id_hash
+    msg['X-Message-ID-Hash'] = message_id_hash.decode('ascii')

=== modified file 'src/mailman/utilities/i18n.py'
--- src/mailman/utilities/i18n.py	2014-11-18 01:29:29 +0000
+++ src/mailman/utilities/i18n.py	2014-12-22 17:38:14 +0000
@@ -29,6 +29,7 @@
 
 
 import os
+import six
 import sys
 import errno
 
@@ -203,7 +204,8 @@
         template = _(fp.read()[:-1])
     finally:
         fp.close()
-    assert isinstance(template, unicode), 'Translated template is not unicode'
+    assert isinstance(template, six.text_type), (
+        'Translated template is not unicode')
     text = expand(template, kw)
     if wrap:
         return wrap_text(text)

=== modified file 'src/mailman/utilities/importer.py'
--- src/mailman/utilities/importer.py	2014-11-09 12:52:58 +0000
+++ src/mailman/utilities/importer.py	2014-12-22 17:38:14 +0000
@@ -48,7 +48,7 @@
 from mailman.interfaces.usermanager import IUserManager
 from mailman.utilities.filesystem import makedirs
 from mailman.utilities.i18n import search
-from urllib2 import URLError
+from six.moves.urllib_error import URLError
 from zope.component import getUtility
 
 
@@ -58,7 +58,7 @@
 
 
 
-def str_to_unicode(value):
+def bytes_to_str(value):
     # Convert a string to unicode when the encoding is not declared.
     if not isinstance(value, bytes):
         return value
@@ -71,8 +71,10 @@
     return value.decode('ascii', 'replace')
 
 
-def unicode_to_string(value):
-    return None if value is None else str(value)
+def str_to_bytes(value):
+    if value is None or isinstance(value, bytes):
+        return value
+    return value.encode('utf-8')
 
 
 def seconds_to_delta(value):
@@ -84,7 +86,7 @@
 
 
 def list_members_to_unicode(value):
-    return [str_to_unicode(item) for item in value]
+    return [bytes_to_str(item) for item in value]
 
 
 
@@ -132,7 +134,7 @@
 def check_language_code(code):
     if code is None:
         return None
-    code = str_to_unicode(code)
+    code = bytes_to_str(code)
     if code not in getUtility(ILanguageManager):
         msg = """Missing language: {0}
 You must add a section describing this language to your mailman.cfg file.
@@ -170,7 +172,7 @@
     forward_unrecognized_bounces_to=UnrecognizedBounceDisposition,
     gateway_to_mail=bool,
     include_rfc2369_headers=bool,
-    moderator_password=unicode_to_string,
+    moderator_password=str_to_bytes,
     newsgroup_moderation=NewsgroupModeration,
     nntp_prefix_subject_too=bool,
     pass_extensions=list_members_to_unicode,
@@ -213,8 +215,10 @@
     ]
 
 EXCLUDES = set((
+    'delivery_status',
     'digest_members',
     'members',
+    'user_options',
     ))
 
 
@@ -243,9 +247,9 @@
         # If the mailing list has a preferred language that isn't registered
         # in the configuration file, hasattr() will swallow the KeyError this
         # raises and return False.  Treat that attribute specially.
-        if hasattr(mlist, key) or key == 'preferred_language':
-            if isinstance(value, str):
-                value = str_to_unicode(value)
+        if key == 'preferred_language' or hasattr(mlist, key):
+            if isinstance(value, bytes):
+                value = bytes_to_str(value)
             # Some types require conversion.
             converter = TYPES.get(key)
             try:
@@ -279,17 +283,19 @@
     # Handle ban list.
     ban_manager = IBanManager(mlist)
     for address in config_dict.get('ban_list', []):
-        ban_manager.ban(str_to_unicode(address))
+        ban_manager.ban(bytes_to_str(address))
     # Handle acceptable aliases.
     acceptable_aliases = config_dict.get('acceptable_aliases', '')
-    if isinstance(acceptable_aliases, basestring):
+    if isinstance(acceptable_aliases, bytes):
+        acceptable_aliases = acceptable_aliases.decode('utf-8')
+    if isinstance(acceptable_aliases, str):
         acceptable_aliases = acceptable_aliases.splitlines()
     alias_set = IAcceptableAliasSet(mlist)
     for address in acceptable_aliases:
         address = address.strip()
         if len(address) == 0:
             continue
-        address = str_to_unicode(address)
+        address = bytes_to_str(address)
         try:
             alias_set.add(address)
         except ValueError:
@@ -343,7 +349,8 @@
         if oldvar not in config_dict:
             continue
         text = config_dict[oldvar]
-        text = text.decode('utf-8', 'replace')
+        if isinstance(text, bytes):
+            text = text.decode('utf-8', 'replace')
         for oldph, newph in convert_placeholders:
             text = text.replace(oldph, newph)
         default_value, default_text  = defaults.get(newvar, (None, None))
@@ -380,8 +387,9 @@
         with codecs.open(filepath, 'w', encoding='utf-8') as fp:
             fp.write(text)
     # Import rosters.
-    members = set(config_dict.get('members', {}).keys()
-                + config_dict.get('digest_members', {}).keys())
+    regulars_set = set(config_dict.get('members', {}))
+    digesters_set = set(config_dict.get('digest_members', {}))
+    members = regulars_set.union(digesters_set)
     import_roster(mlist, config_dict, members, MemberRole.member)
     import_roster(mlist, config_dict, config_dict.get('owner', []),
                   MemberRole.owner)
@@ -407,7 +415,7 @@
     for email in members:
         # For owners and members, the emails can have a mixed case, so
         # lowercase them all.
-        email = str_to_unicode(email).lower()
+        email = bytes_to_str(email).lower()
         if roster.get_member(email) is not None:
             print('{} is already imported with role {}'.format(email, role),
                   file=sys.stderr)
@@ -421,7 +429,7 @@
                 merged_members.update(config_dict.get('members', {}))
                 merged_members.update(config_dict.get('digest_members', {}))
                 if merged_members.get(email, 0) != 0:
-                    original_email = str_to_unicode(merged_members[email])
+                    original_email = bytes_to_str(merged_members[email])
                 else:
                     original_email = email
                 address = usermanager.create_address(original_email)
@@ -449,9 +457,9 @@
         # overwritten.
         if email in config_dict.get('usernames', {}):
             address.display_name = \
-                str_to_unicode(config_dict['usernames'][email])
+                bytes_to_str(config_dict['usernames'][email])
             user.display_name    = \
-                str_to_unicode(config_dict['usernames'][email])
+                bytes_to_str(config_dict['usernames'][email])
         if email in config_dict.get('passwords', {}):
             user.password = config.password_context.encrypt(
                 config_dict['passwords'][email])

=== modified file 'src/mailman/utilities/string.py'
--- src/mailman/utilities/string.py	2014-11-18 01:29:29 +0000
+++ src/mailman/utilities/string.py	2014-12-22 17:38:14 +0000
@@ -73,9 +73,8 @@
     :rtype: string
     """
     try:
-        h = make_header(decode_header(s))
-        ustr = h.__unicode__()
-        line = EMPTYSTRING.join(ustr.splitlines())
+        h = str(make_header(decode_header(s)))
+        line = EMPTYSTRING.join(h.splitlines())
         if in_unicode:
             return line
         else:

=== modified file 'src/mailman/utilities/tests/test_import.py'
--- src/mailman/utilities/tests/test_import.py	2014-11-18 01:29:29 +0000
+++ src/mailman/utilities/tests/test_import.py	2014-12-22 17:38:14 +0000
@@ -27,14 +27,14 @@
 
 
 import os
+import six
 import mock
-import cPickle
 import unittest
 
 from datetime import timedelta, datetime
 from enum import Enum
 from pkg_resources import resource_filename
-from sqlalchemy.exc import IntegrityError
+from six.moves.cPickle import load
 from zope.component import getUtility
 
 from mailman.app.lifecycle import create_list
@@ -77,8 +77,8 @@
     def setUp(self):
         self._mlist = create_list('blank@example.com')
         pickle_file = resource_filename('mailman.testing', 'config.pck')
-        with open(pickle_file) as fp:
-            self._pckdict = cPickle.load(fp)
+        with open(pickle_file, 'rb') as fp:
+            self._pckdict = load(fp)
 
     def _import(self):
         import_config_pck(self._mlist, self._pckdict)
@@ -180,15 +180,15 @@
 
     def test_moderator_password(self):
         # mod_password -> moderator_password
-        self._mlist.moderator_password = str('TESTDATA')
+        self._mlist.moderator_password = b'TESTDATA'
         self._import()
         self.assertEqual(self._mlist.moderator_password, None)
 
     def test_moderator_password_str(self):
         # moderator_password must not be unicode
-        self._pckdict[b'mod_password'] = b'TESTVALUE'
+        self._pckdict['mod_password'] = b'TESTVALUE'
         self._import()
-        self.assertFalse(isinstance(self._mlist.moderator_password, unicode))
+        self.assertNotIsInstance(self._mlist.moderator_password, six.text_type)
         self.assertEqual(self._mlist.moderator_password, b'TESTVALUE')
 
     def test_newsgroup_moderation(self):
@@ -227,7 +227,7 @@
                    'alias2@exemple.com',
                    'non-ascii-\xe8@example.com',
                    ]
-        self._pckdict[b'acceptable_aliases'] = list_to_string(aliases)
+        self._pckdict['acceptable_aliases'] = list_to_string(aliases)
         self._import()
         alias_set = IAcceptableAliasSet(self._mlist)
         self.assertEqual(sorted(alias_set.aliases), aliases)
@@ -236,7 +236,7 @@
         # Values without an '@' sign used to be matched against the local
         # part, now we need to add the '^' sign to indicate it's a regexp.
         aliases = ['invalid-value']
-        self._pckdict[b'acceptable_aliases'] = list_to_string(aliases)
+        self._pckdict['acceptable_aliases'] = list_to_string(aliases)
         self._import()
         alias_set = IAcceptableAliasSet(self._mlist)
         self.assertEqual(sorted(alias_set.aliases),
@@ -246,29 +246,31 @@
         # In some versions of the pickle, this can be a list, not a string
         # (seen in the wild).
         aliases = [b'alias1@example.com', b'alias2@exemple.com' ]
-        self._pckdict[b'acceptable_aliases'] = aliases
+        self._pckdict['acceptable_aliases'] = aliases
         self._import()
         alias_set = IAcceptableAliasSet(self._mlist)
-        self.assertEqual(sorted(alias_set.aliases), aliases)
+        self.assertEqual(sorted(alias_set.aliases),
+                         sorted(a.decode('utf-8') for a in aliases))
 
     def test_info_non_ascii(self):
         # info can contain non-ascii characters.
         info = 'O idioma aceito \xe9 somente Portugu\xeas do Brasil'
-        self._pckdict[b'info'] = info.encode('utf-8')
+        self._pckdict['info'] = info.encode('utf-8')
         self._import()
         self.assertEqual(self._mlist.info, info,
                          'Encoding to UTF-8 is not handled')
         # Test fallback to ascii with replace.
-        self._pckdict[b'info'] = info.encode('iso-8859-1')
+        self._pckdict['info'] = info.encode('iso-8859-1')
         # Suppress warning messages in test output.
         with mock.patch('sys.stderr'):
             self._import()
-        self.assertEqual(self._mlist.info,
-                         unicode(self._pckdict[b'info'], 'ascii', 'replace'),
-                         "We don't fall back to replacing non-ascii chars")
+        self.assertEqual(
+            self._mlist.info,
+            self._pckdict['info'].decode('ascii', 'replace'),
+            "We don't fall back to replacing non-ascii chars")
 
     def test_preferred_language(self):
-        self._pckdict[b'preferred_language'] = b'ja'
+        self._pckdict['preferred_language'] = b'ja'
         english = getUtility(ILanguageManager).get('en')
         japanese = getUtility(ILanguageManager).get('ja')
         self.assertEqual(self._mlist.preferred_language, english)
@@ -283,7 +285,7 @@
         self.assertEqual(self._mlist.preferred_language, english)
 
     def test_new_language(self):
-        self._pckdict[b'preferred_language'] = b'xx_XX'
+        self._pckdict['preferred_language'] = b'xx_XX'
         try:
             self._import()
         except Import21Error as error:
@@ -409,35 +411,35 @@
         # Suppress warning messages in the test output.
         with mock.patch('sys.stderr'):
             import_config_pck(self._mlist, self._pckdict)
-        for key, value in expected.iteritems():
+        for key, value in expected.items():
             self.assertEqual(getattr(self._mlist, key), value)
 
     def test_member_hold(self):
-        self._pckdict[b'member_moderation_action'] = 0
+        self._pckdict['member_moderation_action'] = 0
         self._do_test(dict(default_member_action=Action.hold))
 
     def test_member_reject(self):
-        self._pckdict[b'member_moderation_action'] = 1
+        self._pckdict['member_moderation_action'] = 1
         self._do_test(dict(default_member_action=Action.reject))
 
     def test_member_discard(self):
-        self._pckdict[b'member_moderation_action'] = 2
+        self._pckdict['member_moderation_action'] = 2
         self._do_test(dict(default_member_action=Action.discard))
 
     def test_nonmember_accept(self):
-        self._pckdict[b'generic_nonmember_action'] = 0
+        self._pckdict['generic_nonmember_action'] = 0
         self._do_test(dict(default_nonmember_action=Action.accept))
 
     def test_nonmember_hold(self):
-        self._pckdict[b'generic_nonmember_action'] = 1
+        self._pckdict['generic_nonmember_action'] = 1
         self._do_test(dict(default_nonmember_action=Action.hold))
 
     def test_nonmember_reject(self):
-        self._pckdict[b'generic_nonmember_action'] = 2
+        self._pckdict['generic_nonmember_action'] = 2
         self._do_test(dict(default_nonmember_action=Action.reject))
 
     def test_nonmember_discard(self):
-        self._pckdict[b'generic_nonmember_action'] = 3
+        self._pckdict['generic_nonmember_action'] = 3
         self._do_test(dict(default_nonmember_action=Action.discard))
 
 
@@ -524,9 +526,9 @@
         # if it changed from the default so don't import.  We may do more harm
         # than good and it's easy to change if needed.
         test_value = b'TEST-VALUE'
-        for oldvar, newvar in self._conf_mapping.iteritems():
+        for oldvar, newvar in self._conf_mapping.items():
             self._mlist.mail_host = 'example.com'
-            self._pckdict[b'mail_host'] = b'test.example.com'
+            self._pckdict['mail_host'] = b'test.example.com'
             self._pckdict[str(oldvar)] = test_value
             old_value = getattr(self._mlist, newvar)
             # Suppress warning messages in the test output.
@@ -541,7 +543,7 @@
         for oldvar in self._conf_mapping:
             self._pckdict[str(oldvar)] = b'Ol\xe1!'
         import_config_pck(self._mlist, self._pckdict)
-        for oldvar, newvar in self._conf_mapping.iteritems():
+        for oldvar, newvar in self._conf_mapping.items():
             newattr = getattr(self._mlist, newvar)
             text = decorate(self._mlist, newattr)
             expected = u'Ol\ufffd!'
@@ -557,7 +559,7 @@
         makedirs(os.path.dirname(footer_path))
         with open(footer_path, 'wb') as fp:
             fp.write(footer)
-        self._pckdict[b'msg_footer'] = b'NEW-VALUE'
+        self._pckdict['msg_footer'] = b'NEW-VALUE'
         import_config_pck(self._mlist, self._pckdict)
         text = decorate(self._mlist, self._mlist.footer_uri)
         self.assertEqual(text, 'NEW-VALUE')
@@ -609,6 +611,8 @@
         self._usermanager = getUtility(IUserManager)
         language_manager = getUtility(ILanguageManager)
         for code in self._pckdict['language'].values():
+            if isinstance(code, bytes):
+                code = code.decode('utf-8')
             if code not in language_manager.codes:
                 language_manager.add(code, 'utf-8', code)
 
@@ -641,11 +645,13 @@
             addr = '%s@example.com' % name
             member = self._mlist.members.get_member(addr)
             self.assertIsNotNone(member, 'Address %s was not imported' % addr)
-            self.assertEqual(member.preferred_language.code,
-                             self._pckdict['language'][addr])
+            code = self._pckdict['language'][addr]
+            if isinstance(code, bytes):
+                code = code.decode('utf-8')
+            self.assertEqual(member.preferred_language.code, code)
 
     def test_new_language(self):
-        self._pckdict[b'language']['anne@example.com'] = b'xx_XX'
+        self._pckdict['language']['anne@example.com'] = b'xx_XX'
         try:
             import_config_pck(self._mlist, self._pckdict)
         except Import21Error as error:
@@ -698,7 +704,7 @@
             user = self._usermanager.get_user(addr)
             self.assertIsNotNone(user, 'Address %s was not imported' % addr)
             self.assertEqual(
-                user.password, b'{plaintext}%spass' % name,
+                user.password, '{plaintext}%spass' % name,
                 'Password for %s was not imported' % addr)
 
     def test_same_user(self):
@@ -765,7 +771,7 @@
         self.assertIsNotNone(user, 'User was not imported')
         member = self._mlist.members.get_member('anne@example.com')
         self.assertIsNotNone(member, 'Address was not subscribed')
-        for exp_name, exp_val in expected.iteritems():
+        for exp_name, exp_val in expected.items():
             try:
                 currentval = getattr(member, exp_name)
             except AttributeError:
@@ -831,8 +837,10 @@
 
     def test_multiple_options(self):
         # DontReceiveDuplicates & DisableMime & SuppressPasswordReminder
-        self._pckdict[b'digest_members'] = self._pckdict[b'members'].copy()
-        self._pckdict[b'members'] = dict()
+        # Keys might be Python 2 str/bytes or unicode.
+        members = self._pckdict['members']
+        self._pckdict['digest_members'] = members.copy()
+        self._pckdict['members'] = dict()
         self._do_test(296, dict(
                 receive_list_copy=False,
                 delivery_mode=DeliveryMode.plaintext_digests,

=== modified file 'tox.ini'
--- tox.ini	2014-11-20 01:29:44 +0000
+++ tox.ini	2014-12-22 17:38:14 +0000
@@ -1,5 +1,5 @@
 [tox]
-envlist = py27
+envlist = py27,py34
 recreate = True
 
 [testenv]
@@ -7,6 +7,11 @@
 #sitepackages = True
 usedevelop = True
 
+[testenv:py34]
+commands = python -m nose2 -v
+#sitepackages = True
+usedevelop = True
+
 # This environment requires you to set up PostgreSQL and create a .cfg file
 # somewhere outside of the source tree.
 [testenv:pg]

