summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/app/bounces.py6
-rw-r--r--src/mailman/app/docs/bounces.rst12
-rw-r--r--src/mailman/app/docs/message.rst65
-rw-r--r--src/mailman/app/tests/test_bounces.py19
-rw-r--r--src/mailman/archiving/docs/common.rst9
-rw-r--r--src/mailman/archiving/prototype.py59
-rw-r--r--src/mailman/archiving/tests/__init__.py0
-rw-r--r--src/mailman/archiving/tests/test_prototype.py174
-rw-r--r--src/mailman/commands/docs/info.rst1
-rw-r--r--src/mailman/config/config.py1
-rw-r--r--src/mailman/config/schema.cfg3
-rw-r--r--src/mailman/docs/NEWS.rst6
-rw-r--r--src/mailman/email/message.py14
13 files changed, 315 insertions, 54 deletions
diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py
index 69250ea5a..3816369fa 100644
--- a/src/mailman/app/bounces.py
+++ b/src/mailman/app/bounces.py
@@ -225,9 +225,9 @@ def send_probe(member, msg):
notice = MIMEText(text, _charset=mlist.preferred_language.charset)
probe.attach(notice)
probe.attach(MIMEMessage(msg))
- # Send without Precedence: bulk. (LP: #808821)
- probe.send(mlist, envsender=probe_sender, verp=False, probe_token=token
- noprecedence=True)
+ # Probes should not have the Precedence: bulk header.
+ probe.send(mlist, envsender=probe_sender, verp=False, probe_token=token,
+ add_precedence=False)
return token
diff --git a/src/mailman/app/docs/bounces.rst b/src/mailman/app/docs/bounces.rst
index f825064e3..5510f2207 100644
--- a/src/mailman/app/docs/bounces.rst
+++ b/src/mailman/app/docs/bounces.rst
@@ -12,12 +12,12 @@ Mailman can bounce messages back to the original sender. This is essentially
equivalent to rejecting the message with notification. Mailing lists can
bounce a message with an optional error message.
- >>> mlist = create_list('_xtest@example.com')
+ >>> mlist = create_list('text@example.com')
Any message can be bounced.
>>> msg = message_from_string("""\
- ... To: _xtest@example.com
+ ... To: text@example.com
... From: aperson@example.com
... Subject: Something important
...
@@ -36,7 +36,7 @@ to the original message author.
1
>>> print items[0].msg.as_string()
Subject: Something important
- From: _xtest-owner@example.com
+ From: text-owner@example.com
To: aperson@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="..."
@@ -54,7 +54,7 @@ to the original message author.
Content-Type: message/rfc822
MIME-Version: 1.0
<BLANKLINE>
- To: _xtest@example.com
+ To: text@example.com
From: aperson@example.com
Subject: Something important
<BLANKLINE>
@@ -74,7 +74,7 @@ passed in as an instance of a ``RejectMessage`` exception.
1
>>> print items[0].msg.as_string()
Subject: Something important
- From: _xtest-owner@example.com
+ From: text-owner@example.com
To: aperson@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="..."
@@ -92,7 +92,7 @@ passed in as an instance of a ``RejectMessage`` exception.
Content-Type: message/rfc822
MIME-Version: 1.0
<BLANKLINE>
- To: _xtest@example.com
+ To: text@example.com
From: aperson@example.com
Subject: Something important
<BLANKLINE>
diff --git a/src/mailman/app/docs/message.rst b/src/mailman/app/docs/message.rst
index 11ab32b22..3c3fd8ea8 100644
--- a/src/mailman/app/docs/message.rst
+++ b/src/mailman/app/docs/message.rst
@@ -2,7 +2,7 @@
Messages
========
-Mailman has its own Message classes, derived from the standard
+Mailman has its own `Message` classes, derived from the standard
``email.message.Message`` class, but providing additional useful methods.
@@ -13,7 +13,7 @@ When Mailman needs to send a message to a user, it creates a
``UserNotification`` instance, and then calls the ``.send()`` method on this
object. This method requires a mailing list instance.
- >>> mlist = create_list('_xtest@example.com')
+ >>> mlist = create_list('test@example.com')
The ``UserNotification`` constructor takes the recipient address, the sender
address, an optional subject, optional body text, and optional language.
@@ -21,25 +21,23 @@ address, an optional subject, optional body text, and optional language.
>>> from mailman.email.message import UserNotification
>>> msg = UserNotification(
... 'aperson@example.com',
- ... '_xtest@example.com',
+ ... 'test@example.com',
... 'Something you need to know',
... 'I needed to tell you this.')
>>> msg.send(mlist)
The message will end up in the `virgin` queue.
- >>> switchboard = config.switchboards['virgin']
- >>> len(switchboard.files)
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
1
- >>> filebase = switchboard.files[0]
- >>> qmsg, qmsgdata = switchboard.dequeue(filebase)
- >>> switchboard.finish(filebase)
- >>> print qmsg.as_string()
+ >>> print messages[0].msg.as_string()
MIME-Version: 1.0
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit
Subject: Something you need to know
- From: _xtest@example.com
+ From: test@example.com
To: aperson@example.com
Message-ID: ...
Date: ...
@@ -47,40 +45,45 @@ The message will end up in the `virgin` queue.
<BLANKLINE>
I needed to tell you this.
-The message above got a Precedence: bulk header added by default. If the
-message we're sending already has a Precedence: header, it shouldn't be
+The message above got a `Precedence: bulk` header added by default. If the
+message we're sending already has a `Precedence:` header, it shouldn't be
changed.
>>> del msg['precedence']
>>> msg['Precedence'] = 'list'
>>> msg.send(mlist)
-Again, the message will end up in the `virgin` queue but with our Precedence:
-header.
+Again, the message will end up in the `virgin` queue but with the original
+`Precedence:` header.
- >>> switchboard = config.switchboards['virgin']
- >>> len(switchboard.files)
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
1
- >>> filebase = switchboard.files[0]
- >>> qmsg, qmsgdata = switchboard.dequeue(filebase)
- >>> switchboard.finish(filebase)
- >>> qmsg['precedence'] == 'list'
- True
+ >>> print messages[0].msg['precedence']
+ list
-Sometimes we want to send the message without a Precedence: header such as
+Sometimes we want to send the message without a `Precedence:` header such as
when we send a probe message.
>>> del msg['precedence']
- >>> msg.send(mlist, noprecedence=True)
+ >>> msg.send(mlist, add_precedence=False)
Again, the message will end up in the `virgin` queue but without the
-Precedence: header.
+`Precedence:` header.
- >>> switchboard = config.switchboards['virgin']
- >>> len(switchboard.files)
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
1
- >>> filebase = switchboard.files[0]
- >>> qmsg, qmsgdata = switchboard.dequeue(filebase)
- >>> switchboard.finish(filebase)
- >>> 'precedence' in qmsg
- False
+ >>> print messages[0].msg['precedence']
+ None
+
+However, if the message already has a `Precedence:` header, setting the
+`precedence=False` argument will have no effect.
+
+ >>> msg['Precedence'] = 'junk'
+ >>> msg.send(mlist, add_precedence=False)
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg['precedence']
+ junk
diff --git a/src/mailman/app/tests/test_bounces.py b/src/mailman/app/tests/test_bounces.py
index be2c5cb78..d0d94df5e 100644
--- a/src/mailman/app/tests/test_bounces.py
+++ b/src/mailman/app/tests/test_bounces.py
@@ -21,6 +21,11 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
+ 'TestMaybeForward',
+ 'TestProbe',
+ 'TestSendProbe',
+ 'TestSendProbeNonEnglish',
+ 'TestVERP',
]
@@ -212,7 +217,7 @@ Message-ID: <first>
self.assertEqual(set(pendable.keys()),
set(['member_id', 'message_id']))
# member_ids are pended as unicodes.
- self.assertEqual(uuid.UUID(hex=pendable['member_id']),
+ self.assertEqual(uuid.UUID(hex=pendable['member_id']),
self._member.member_id)
self.assertEqual(pendable['message_id'], '<first>')
@@ -264,10 +269,16 @@ Message-ID: <first>
# Check the headers of the outer message.
token = send_probe(self._member, self._msg)
message = get_queue_messages('virgin')[0].msg
- self.assertEqual(message['From'],
+ self.assertEqual(message['from'],
'test-bounces+{0}@example.com'.format(token))
- self.assertEqual(message['To'], 'anne@example.com')
- self.assertEqual(message['Subject'], 'Test mailing list probe message')
+ self.assertEqual(message['to'], 'anne@example.com')
+ self.assertEqual(message['subject'], 'Test mailing list probe message')
+
+ def test_no_precedence_header(self):
+ # Probe messages should not have a Precedence header (LP: #808821).
+ send_probe(self._member, self._msg)
+ message = get_queue_messages('virgin')[0].msg
+ self.assertEqual(message['precedence'], None)
diff --git a/src/mailman/archiving/docs/common.rst b/src/mailman/archiving/docs/common.rst
index 45ec8f194..5f7cfe42b 100644
--- a/src/mailman/archiving/docs/common.rst
+++ b/src/mailman/archiving/docs/common.rst
@@ -62,12 +62,13 @@ The archiver is also able to archive the message.
>>> os.path.exists(pckpath)
True
-Note however that the prototype archiver can't archive messages.
+The `prototype` archiver archives messages to a maildir.
>>> archivers['prototype'].archive_message(mlist, msg)
- Traceback (most recent call last):
- ...
- NotImplementedError
+ >>> archive_path = os.path.join(
+ ... config.ARCHIVE_DIR, 'prototype', mlist.fqdn_listname, 'new')
+ >>> len(os.listdir(archive_path))
+ 1
The Mail-Archive.com
diff --git a/src/mailman/archiving/prototype.py b/src/mailman/archiving/prototype.py
index 55d78074e..453c6c770 100644
--- a/src/mailman/archiving/prototype.py
+++ b/src/mailman/archiving/prototype.py
@@ -25,11 +25,22 @@ __all__ = [
]
+import os
+import errno
+import logging
+
+from datetime import timedelta
+from mailbox import Maildir
from urlparse import urljoin
+
+from flufl.lock import Lock, TimeOutError
from zope.interface import implements
+from mailman.config import config
from mailman.interfaces.archiver import IArchiver
+log = logging.getLogger('mailman.error')
+
class Prototype:
@@ -61,5 +72,49 @@ class Prototype:
@staticmethod
def archive_message(mlist, message):
- """See `IArchiver`."""
- raise NotImplementedError
+ """See `IArchiver`.
+
+ This archiver saves messages into a maildir.
+ """
+ archive_dir = os.path.join(config.ARCHIVE_DIR, 'prototype')
+ try:
+ os.makedirs(archive_dir, 0775)
+ except OSError as error:
+ # If this already exists, then we're fine
+ if error.errno != errno.EEXIST:
+ raise
+
+ # Maildir will throw an error if the directories are partially created
+ # (for instance the toplevel exists but cur, new, or tmp do not)
+ # therefore we don't create the toplevel as we did above.
+ list_dir = os.path.join(archive_dir, mlist.fqdn_listname)
+ mailbox = Maildir(list_dir, create=True, factory=None)
+ lock_file = os.path.join(
+ config.LOCK_DIR, '{0}-maildir.lock'.format(mlist.fqdn_listname))
+
+ # Lock the maildir as Maildir.add() is not threadsafe. Don't use the
+ # context manager because it's not an error if we can't acquire the
+ # archiver lock. We'll just log the problem and continue.
+ #
+ # XXX 2012-03-14 BAW: When we extend the chain/pipeline architecture
+ # to other runners, e.g. the archive runner, it would be better to let
+ # any TimeOutError propagate up. That would cause the message to be
+ # re-queued and tried again later, rather than being discarded as
+ # happens now below.
+ lock = Lock(lock_file)
+ try:
+ lock.lock(timeout=timedelta(seconds=1))
+ # Add the message to the maildir. The return value could be used
+ # to construct the file path if necessary. E.g.
+ #
+ # os.path.join(archive_dir, mlist.fqdn_listname, 'new',
+ # message_key)
+ mailbox.add(message)
+ except TimeOutError:
+ # Log the error and go on.
+ log.error('Unable to acquire prototype archiver lock for {0}, '
+ 'discarding: {1}'.format(
+ mlist.fqdn_listname,
+ message.get('message-id', 'n/a')))
+ finally:
+ lock.unlock(unconditionally=True)
diff --git a/src/mailman/archiving/tests/__init__.py b/src/mailman/archiving/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/mailman/archiving/tests/__init__.py
diff --git a/src/mailman/archiving/tests/test_prototype.py b/src/mailman/archiving/tests/test_prototype.py
new file mode 100644
index 000000000..7f48c5cfd
--- /dev/null
+++ b/src/mailman/archiving/tests/test_prototype.py
@@ -0,0 +1,174 @@
+# Copyright (C) 2012 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 prototype archiver."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'TestPrototypeArchiver',
+ ]
+
+
+import os
+import shutil
+import tempfile
+import unittest
+import threading
+
+from email import message_from_file
+from flufl.lock import Lock
+
+from mailman.app.lifecycle import create_list
+from mailman.archiving.prototype import Prototype
+from mailman.config import config
+from mailman.testing.helpers import LogFileMark
+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
+
+
+class TestPrototypeArchiver(unittest.TestCase):
+ """Test the prototype archiver."""
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ # Create a fake mailing list and message object
+ self._msg = mfs("""\
+To: test@example.com
+From: anne@example.com
+Subject: Testing the test list
+Message-ID: <ant>
+X-Message-ID-Hash: MS6QLWERIJLGCRF44J7USBFDELMNT2BW
+
+Tests are better than no tests
+but the water deserves to be swum.
+""")
+ self._mlist = create_list('test@example.com')
+ config.db.commit()
+ # Set up a temporary directory for the prototype archiver so that it's
+ # easier to clean up.
+ self._tempdir = tempfile.mkdtemp()
+ config.push('prototype', """
+ [paths.testing]
+ archive_dir: {0}
+ """.format(self._tempdir))
+ # Capture the structure of a maildir.
+ self._expected_dir_structure = set(
+ (os.path.join(config.ARCHIVE_DIR, path) for path in (
+ 'prototype',
+ os.path.join('prototype', self._mlist.fqdn_listname),
+ os.path.join('prototype', self._mlist.fqdn_listname, 'cur'),
+ os.path.join('prototype', self._mlist.fqdn_listname, 'new'),
+ os.path.join('prototype', self._mlist.fqdn_listname, 'tmp'),
+ )))
+ self._expected_dir_structure.add(config.ARCHIVE_DIR)
+
+ def tearDown(self):
+ shutil.rmtree(self._tempdir)
+ config.pop('prototype')
+
+ def _find(self, path):
+ all_filenames = set()
+ for dirpath, dirnames, filenames in os.walk(path):
+ if not isinstance(dirpath, unicode):
+ dirpath = unicode(dirpath)
+ all_filenames.add(dirpath)
+ for filename in filenames:
+ new_filename = filename
+ if not isinstance(filename, unicode):
+ new_filename = unicode(filename)
+ all_filenames.add(os.path.join(dirpath, new_filename))
+ return all_filenames
+
+ def test_archive_maildir_created(self):
+ # Archiving a message to the prototype archiver should create the
+ # expected directory structure.
+ Prototype.archive_message(self._mlist, self._msg)
+ all_filenames = self._find(config.ARCHIVE_DIR)
+ # Check that the directory structure has been created and we have one
+ # more file (the archived message) than expected directories.
+ archived_messages = all_filenames - self._expected_dir_structure
+ self.assertEqual(len(archived_messages), 1)
+ self.assertTrue(
+ archived_messages.pop().startswith(
+ os.path.join(config.ARCHIVE_DIR, 'prototype',
+ self._mlist.fqdn_listname, 'new')))
+
+ def test_archive_maildir_existence_does_not_raise(self):
+ # Archiving a second message does not cause an EEXIST to be raised
+ # when a second message is archived.
+ new_dir = None
+ Prototype.archive_message(self._mlist, self._msg)
+ for directory in ('cur', 'new', 'tmp'):
+ path = os.path.join(config.ARCHIVE_DIR, 'prototype',
+ self._mlist.fqdn_listname, directory)
+ if directory == 'new':
+ new_dir = path
+ self.assertTrue(os.path.isdir(path))
+ # There should be one message in the 'new' directory.
+ self.assertEqual(len(os.listdir(new_dir)), 1)
+ # Archive a second message. If an exception occurs, let it fail the
+ # test. Afterward, two messages should be in the 'new' directory.
+ del self._msg['message-id']
+ del self._msg['x-message-id-hash']
+ self._msg['Message-ID'] = '<bee>'
+ add_message_hash(self._msg)
+ Prototype.archive_message(self._mlist, self._msg)
+ self.assertEqual(len(os.listdir(new_dir)), 2)
+
+ def test_archive_lock_used(self):
+ # Test that locking the maildir when adding works as a failure here
+ # could mean we lose mail.
+ lock_file = os.path.join(
+ config.LOCK_DIR, '{0}-maildir.lock'.format(
+ self._mlist.fqdn_listname))
+ with Lock(lock_file):
+ # Acquire the archiver lock, then make sure the archiver logs the
+ # fact that it could not acquire the lock.
+ archive_thread = threading.Thread(
+ target=Prototype.archive_message,
+ args=(self._mlist, self._msg))
+ mark = LogFileMark('mailman.error')
+ archive_thread.run()
+ # Test that the archiver output the correct error.
+ line = mark.readline()
+ self.assertEqual(
+ # Strip out the timestamp.
+ line[28:-1],
+ 'Unable to acquire prototype archiver lock for {0}, '
+ 'discarding: {1}'.format(
+ self._mlist.fqdn_listname,
+ self._msg.get('message-id')))
+ # Check that the message didn't get archived.
+ created_files = self._find(config.ARCHIVE_DIR)
+ self.assertEqual(self._expected_dir_structure, created_files)
+
+ def test_prototype_archiver_good_path(self):
+ # Verify the good path; the message gets archived.
+ Prototype.archive_message(self._mlist, self._msg)
+ new_path = os.path.join(
+ config.ARCHIVE_DIR, 'prototype', self._mlist.fqdn_listname, 'new')
+ archived_messages = list(os.listdir(new_path))
+ self.assertEqual(len(archived_messages), 1)
+ # Check that the email has been added.
+ with open(os.path.join(new_path, archived_messages[0])) as fp:
+ archived_message = message_from_file(fp)
+ self.assertEqual(self._msg.as_string(), archived_message.as_string())
diff --git a/src/mailman/commands/docs/info.rst b/src/mailman/commands/docs/info.rst
index 34883711e..ad034a1a6 100644
--- a/src/mailman/commands/docs/info.rst
+++ b/src/mailman/commands/docs/info.rst
@@ -59,6 +59,7 @@ The File System Hierarchy layout is the same every by definition.
Python ...
...
File system paths:
+ ARCHIVE_DIR = /var/lib/mailman/archives
BIN_DIR = /sbin
DATA_DIR = /var/lib/mailman/data
ETC_DIR = /etc
diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py
index 034b76b4f..e3b4f88a7 100644
--- a/src/mailman/config/config.py
+++ b/src/mailman/config/config.py
@@ -173,6 +173,7 @@ class Configuration:
lock_dir = category.lock_dir,
log_dir = category.log_dir,
messages_dir = category.messages_dir,
+ archive_dir = category.archive_dir,
pipermail_private_dir = category.pipermail_private_dir,
pipermail_public_dir = category.pipermail_public_dir,
queue_dir = category.queue_dir,
diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg
index e662633e6..7e15ab82f 100644
--- a/src/mailman/config/schema.cfg
+++ b/src/mailman/config/schema.cfg
@@ -113,6 +113,9 @@ etc_dir: $var_dir/etc
ext_dir: $var_dir/ext
# Directory where the default IMessageStore puts its messages.
messages_dir: $var_dir/messages
+# Directory for archive backends to store their messages in. Archivers should
+# create a subdirectory in here to store their files.
+archive_dir: $var_dir/archives
# Directory for public Pipermail archiver artifacts.
pipermail_public_dir: $var_dir/archives/public
# Directory for private Pipermail archiver artifacts.
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index 71cdcae6e..7888ed8fd 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -46,6 +46,8 @@ Architecture
attribute on the message object, instead of trusting a possibly incorrect
value if it's already set. The individual `IArchiver` implementations no
longer set the `X-Message-ID-Hash` header.
+ * The Prototype archiver now stores its files in maildir format inside of
+ `$var_dir/archives/prototype`, given by Toshio Kuratomi.
Database
--------
@@ -105,8 +107,8 @@ Commands
Bug fixes
---------
- * Subscription disabled warnings are now sent without a Precedence:
- header. (LP: #808821)
+ * Subscription disabled probe warning notification messages are now sent
+ without a `Precedence:` header. Given by Mark Sapiro. (LP: #808821)
* Fixed KeyError in retry runner, contributed by Stephen A. Goss.
(LP: #872391)
* Fixed bogus use of `bounce_processing` attribute (should have been
diff --git a/src/mailman/email/message.py b/src/mailman/email/message.py
index 28d94523f..efe8879b1 100644
--- a/src/mailman/email/message.py
+++ b/src/mailman/email/message.py
@@ -178,10 +178,20 @@ class UserNotification(Message):
self['To'] = recipients
self.recipients = set([recipients])
- def send(self, mlist, noprecedence=False, **_kws):
+ def send(self, mlist, add_precedence=True, **_kws):
"""Sends the message by enqueuing it to the 'virgin' queue.
This is used for all internally crafted messages.
+
+ :param mlist: The mailing list to send the message to.
+ :type mlist: `IMailingList`
+ :param add_precedence: Flag indicating whether a `Precedence: bulk`
+ header should be added to the message or not.
+ :type add_precedence: bool
+
+ This function also accepts arbitrary keyword arguments. The key/value
+ pairs for **kws is added to the metadata dictionary associated with
+ the enqueued message.
"""
# Since we're crafting the message from whole cloth, let's make sure
# this message has a Message-ID.
@@ -193,7 +203,7 @@ class UserNotification(Message):
# UserNotifications are typically for admin messages, and for messages
# other than list explosions. Send these out as Precedence: bulk, but
# don't override an existing Precedence: header.
- if 'precedence' not in self and not noprecedence:
+ if 'precedence' not in self and add_precedence:
self['Precedence'] = 'bulk'
self._enqueue(mlist, **_kws)