summaryrefslogtreecommitdiff
path: root/src/mailman/queue/docs
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/queue/docs')
-rw-r--r--src/mailman/queue/docs/OVERVIEW.txt78
-rw-r--r--src/mailman/queue/docs/archiver.txt34
-rw-r--r--src/mailman/queue/docs/command.txt170
-rw-r--r--src/mailman/queue/docs/incoming.txt200
-rw-r--r--src/mailman/queue/docs/lmtp.txt103
-rw-r--r--src/mailman/queue/docs/news.txt157
-rw-r--r--src/mailman/queue/docs/outgoing.txt75
-rw-r--r--src/mailman/queue/docs/runner.txt72
-rw-r--r--src/mailman/queue/docs/switchboard.txt182
9 files changed, 1071 insertions, 0 deletions
diff --git a/src/mailman/queue/docs/OVERVIEW.txt b/src/mailman/queue/docs/OVERVIEW.txt
new file mode 100644
index 000000000..643fa8a5c
--- /dev/null
+++ b/src/mailman/queue/docs/OVERVIEW.txt
@@ -0,0 +1,78 @@
+Alias overview
+==============
+
+A typical Mailman list exposes nine aliases which point to seven different
+wrapped scripts. E.g. for a list named `mylist', you'd have:
+
+ mylist-bounces -> bounces
+ mylist-confirm -> confirm
+ mylist-join -> join (-subscribe is an alias)
+ mylist-leave -> leave (-unsubscribe is an alias)
+ mylist-owner -> owner
+ mylist -> post
+ mylist-request -> request
+
+-request, -join, and -leave are a robot addresses; their sole purpose is to
+process emailed commands, although the latter two are hardcoded to
+subscription and unsubscription requests. -bounces is the automated bounce
+processor, and all messages to list members have their return address set to
+-bounces. If the bounce processor fails to extract a bouncing member address,
+it can optionally forward the message on to the list owners.
+
+-owner is for reaching a human operator with minimal list interaction (i.e. no
+bounce processing). -confirm is another robot address which processes replies
+to VERP-like confirmation notices.
+
+So delivery flow of messages look like this:
+
+ joerandom ---> mylist ---> list members
+ | |
+ | |[bounces]
+ | mylist-bounces <---+ <-------------------------------+
+ | | |
+ | +--->[internal bounce processing] |
+ | ^ | |
+ | | | [bounce found] |
+ | [bounces *] +--->[register and discard] |
+ | | | | |
+ | | | |[*] |
+ | [list owners] |[no bounce found] | |
+ | ^ | | |
+ | | | | |
+ +-------> mylist-owner <--------+ | |
+ | | |
+ | data/owner-bounces.mbox <--[site list] <---+ |
+ | |
+ +-------> mylist-join--+ |
+ | | |
+ +------> mylist-leave--+ |
+ | | |
+ | v |
+ +-------> mylist-request |
+ | | |
+ | +---> [command processor] |
+ | | |
+ +-----> mylist-confirm ----> +---> joerandom |
+ | |
+ |[bounces] |
+ +----------------------+
+
+A person can send an email to the list address (for posting), the -owner
+address (to reach the human operator), or the -confirm, -join, -leave, and
+-request mailbots. Message to the list address are then forwarded on to the
+list membership, with bounces directed to the -bounces address.
+
+[*] Messages sent to the -owner address are forwarded on to the list
+owner/moderators. All -owner destined messages have their bounces directed to
+the site list -bounces address, regardless of whether a human sent the message
+or the message was crafted internally. The intention here is that the site
+owners want to be notified when one of their list owners' addresses starts
+bouncing (yes, the will be automated in a future release).
+
+Any messages to site owners has their bounces directed to a special
+"loop-killer" address, which just dumps the message into
+data/owners-bounces.mbox.
+
+Finally, message to any of the mailbots causes the requested action to be
+performed. Results notifications are sent to the author of the message, which
+all bounces pointing back to the -bounces address.
diff --git a/src/mailman/queue/docs/archiver.txt b/src/mailman/queue/docs/archiver.txt
new file mode 100644
index 000000000..601857cd9
--- /dev/null
+++ b/src/mailman/queue/docs/archiver.txt
@@ -0,0 +1,34 @@
+Archiving
+=========
+
+Mailman can archive to any number of archivers that adhere to the IArchiver
+interface. By default, there's a Pipermail archiver.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'test@example.com')
+ >>> commit()
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: test@example.com
+ ... Subject: My first post
+ ... Message-ID: <first>
+ ...
+ ... First post!
+ ... """)
+
+ >>> archiver_queue = config.switchboards['archive']
+ >>> ignore = archiver_queue.enqueue(msg, {}, listname=mlist.fqdn_listname)
+
+ >>> from mailman.queue.archive import ArchiveRunner
+ >>> from mailman.testing.helpers import make_testable_runner
+ >>> runner = make_testable_runner(ArchiveRunner)
+ >>> runner.run()
+
+ # The best we can do is verify some landmark exists. Let's use the
+ # Pipermail pickle file exists.
+ >>> listname = mlist.fqdn_listname
+ >>> import os
+ >>> os.path.exists(os.path.join(
+ ... config.PUBLIC_ARCHIVE_FILE_DIR, listname, 'pipermail.pck'))
+ True
diff --git a/src/mailman/queue/docs/command.txt b/src/mailman/queue/docs/command.txt
new file mode 100644
index 000000000..0b384de01
--- /dev/null
+++ b/src/mailman/queue/docs/command.txt
@@ -0,0 +1,170 @@
+The command queue runner
+========================
+
+This queue runner's purpose is to process and respond to email commands.
+Commands are extensible using the Mailman plugin system, but Mailman comes
+with a number of email commands out of the box. These are processed when a
+message is sent to the list's -request address.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'test@example.com')
+
+
+A command in the Subject
+------------------------
+
+For example, the 'echo' command simply echoes the original command back to the
+sender. The command can be in the Subject header.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: test-request@example.com
+ ... Subject: echo hello
+ ... Message-ID: <aardvark>
+ ...
+ ... """)
+
+ >>> from mailman.inject import inject_message
+ >>> inject_message(mlist, msg, switchboard='command')
+ >>> from mailman.queue.command import CommandRunner
+ >>> from mailman.testing.helpers import make_testable_runner
+ >>> command = make_testable_runner(CommandRunner)
+ >>> command.run()
+
+And now the response is in the virgin queue.
+
+ >>> from mailman.queue import Switchboard
+ >>> virgin_queue = config.switchboards['virgin']
+ >>> len(virgin_queue.files)
+ 1
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> item = get_queue_messages('virgin')[0]
+ >>> print item.msg.as_string()
+ Subject: The results of your email commands
+ From: test-bounces@example.com
+ To: aperson@example.com
+ ...
+ <BLANKLINE>
+ The results of your email command are provided below.
+ <BLANKLINE>
+ - Original message details:
+ From: aperson@example.com
+ Subject: echo hello
+ Date: ...
+ Message-ID: <aardvark>
+ <BLANKLINE>
+ - Results:
+ echo hello
+ <BLANKLINE>
+ - Done.
+ <BLANKLINE>
+ >>> sorted(item.msgdata.items())
+ [..., ('listname', u'test@example.com'), ...,
+ ('recips', [u'aperson@example.com']),
+ ...]
+
+
+A command in the body
+---------------------
+
+The command can also be found in the body of the message, as long as the
+message is plain text.
+
+ >>> msg = message_from_string("""\
+ ... From: bperson@example.com
+ ... To: test-request@example.com
+ ... Message-ID: <bobcat>
+ ...
+ ... echo foo bar
+ ... """)
+
+ >>> inject_message(mlist, msg, switchboard='command')
+ >>> command.run()
+ >>> len(virgin_queue.files)
+ 1
+ >>> item = get_queue_messages('virgin')[0]
+ >>> print item.msg.as_string()
+ Subject: The results of your email commands
+ From: test-bounces@example.com
+ To: bperson@example.com
+ ...
+ Precedence: bulk
+ <BLANKLINE>
+ The results of your email command are provided below.
+ <BLANKLINE>
+ - Original message details:
+ From: bperson@example.com
+ Subject: n/a
+ Date: ...
+ Message-ID: <bobcat>
+ <BLANKLINE>
+ - Results:
+ echo foo bar
+ <BLANKLINE>
+ - Done.
+ <BLANKLINE>
+
+
+Stopping command processing
+---------------------------
+
+The 'end' command stops email processing, so that nothing following is looked
+at by the command queue.
+
+ >>> msg = message_from_string("""\
+ ... From: cperson@example.com
+ ... To: test-request@example.com
+ ... Message-ID: <caribou>
+ ...
+ ... echo foo bar
+ ... end ignored
+ ... echo baz qux
+ ... """)
+
+ >>> inject_message(mlist, msg, switchboard='command')
+ >>> command.run()
+ >>> len(virgin_queue.files)
+ 1
+ >>> item = get_queue_messages('virgin')[0]
+ >>> print item.msg.as_string()
+ Subject: The results of your email commands
+ ...
+ <BLANKLINE>
+ - Results:
+ echo foo bar
+ <BLANKLINE>
+ - Unprocessed:
+ echo baz qux
+ <BLANKLINE>
+ - Done.
+ <BLANKLINE>
+
+The 'stop' command is an alias for 'end'.
+
+ >>> msg = message_from_string("""\
+ ... From: cperson@example.com
+ ... To: test-request@example.com
+ ... Message-ID: <caribou>
+ ...
+ ... echo foo bar
+ ... stop ignored
+ ... echo baz qux
+ ... """)
+
+ >>> inject_message(mlist, msg, switchboard='command')
+ >>> command.run()
+ >>> len(virgin_queue.files)
+ 1
+ >>> item = get_queue_messages('virgin')[0]
+ >>> print item.msg.as_string()
+ Subject: The results of your email commands
+ ...
+ <BLANKLINE>
+ - Results:
+ echo foo bar
+ <BLANKLINE>
+ - Unprocessed:
+ echo baz qux
+ <BLANKLINE>
+ - Done.
+ <BLANKLINE>
diff --git a/src/mailman/queue/docs/incoming.txt b/src/mailman/queue/docs/incoming.txt
new file mode 100644
index 000000000..deb340e71
--- /dev/null
+++ b/src/mailman/queue/docs/incoming.txt
@@ -0,0 +1,200 @@
+The incoming queue runner
+=========================
+
+This runner's sole purpose in life is to decide the disposition of the
+message. It can either be accepted for delivery, rejected (i.e. bounced),
+held for moderator approval, or discarded.
+
+The runner operates by processing chains on a message/metadata pair in the
+context of a mailing list. Each mailing list may have a 'start chain' where
+processing begins, with a global default. This chain is processed with the
+message eventually ending up in one of the four disposition states described
+above.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'_xtest@example.com')
+ >>> mlist.start_chain
+ u'built-in'
+
+
+Accepted messages
+-----------------
+
+We have a message that is going to be sent to the mailing list. This message
+is so perfectly fine for posting that it will be accepted and forward to the
+pipeline queue.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... Subject: My first post
+ ... Message-ID: <first>
+ ...
+ ... First post!
+ ... """)
+
+Normally, the upstream mail server would drop the message in the incoming
+queue, but this is an effective simulation.
+
+ >>> from mailman.inject import inject_message
+ >>> inject_message(mlist, msg)
+
+The incoming queue runner runs until it is empty.
+
+ >>> from mailman.queue.incoming import IncomingRunner
+ >>> from mailman.testing.helpers import make_testable_runner
+ >>> incoming = make_testable_runner(IncomingRunner, 'in')
+ >>> incoming.run()
+
+And now the message is in the pipeline queue.
+
+ >>> pipeline_queue = config.switchboards['pipeline']
+ >>> len(pipeline_queue.files)
+ 1
+ >>> incoming_queue = config.switchboards['in']
+ >>> len(incoming_queue.files)
+ 0
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> item = get_queue_messages('pipeline')[0]
+ >>> print item.msg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID: <first>
+ Date: ...
+ X-Mailman-Rule-Misses: approved; emergency; loop; administrivia;
+ implicit-dest;
+ max-recipients; max-size; news-moderation; no-subject;
+ suspicious-header
+ <BLANKLINE>
+ First post!
+ <BLANKLINE>
+ >>> sorted(item.msgdata.items())
+ [...('envsender', u'noreply@example.com')...('tolist', True)...]
+
+
+Held messages
+-------------
+
+The list moderator sets the emergency flag on the mailing list. The built-in
+chain will now hold all posted messages, so nothing will show up in the
+pipeline queue.
+
+ # XXX This checks the vette log file because there is no other evidence
+ # that this chain has done anything.
+ >>> import os
+ >>> fp = open(os.path.join(config.LOG_DIR, 'vette'))
+ >>> fp.seek(0, 2)
+
+ >>> mlist.emergency = True
+ >>> inject_message(mlist, msg)
+ >>> file_pos = fp.tell()
+ >>> incoming.run()
+ >>> len(pipeline_queue.files)
+ 0
+ >>> len(incoming_queue.files)
+ 0
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... HOLD: _xtest@example.com post from aperson@example.com held,
+ message-id=<first>: n/a
+ <BLANKLINE>
+
+ >>> mlist.emergency = False
+
+
+Discarded messages
+------------------
+
+Another possibility is that the message would get immediately discarded. The
+built-in chain does not have such a disposition by default, so let's craft a
+new chain and set it as the mailing list's start chain.
+
+ >>> from mailman.chains.base import Chain, Link
+ >>> from mailman.interfaces.chain import LinkAction
+ >>> truth_rule = config.rules['truth']
+ >>> discard_chain = config.chains['discard']
+ >>> test_chain = Chain('always-discard', u'Testing discards')
+ >>> link = Link(truth_rule, LinkAction.jump, discard_chain)
+ >>> test_chain.append_link(link)
+ >>> mlist.start_chain = u'always-discard'
+
+ >>> inject_message(mlist, msg)
+ >>> file_pos = fp.tell()
+ >>> incoming.run()
+ >>> len(pipeline_queue.files)
+ 0
+ >>> len(incoming_queue.files)
+ 0
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... DISCARD: <first>
+ <BLANKLINE>
+
+ >>> del config.chains['always-discard']
+
+
+Rejected messages
+-----------------
+
+Similar to discarded messages, a message can be rejected, or bounced back to
+the original sender. Again, the built-in chain doesn't support this so we'll
+just create a new chain that does.
+
+ >>> reject_chain = config.chains['reject']
+ >>> test_chain = Chain('always-reject', u'Testing rejections')
+ >>> link = Link(truth_rule, LinkAction.jump, reject_chain)
+ >>> test_chain.append_link(link)
+ >>> mlist.start_chain = u'always-reject'
+
+The virgin queue needs to be cleared out due to artifacts from the previous
+tests above.
+
+ >>> virgin_queue = config.switchboards['virgin']
+ >>> ignore = get_queue_messages('virgin')
+
+ >>> inject_message(mlist, msg)
+ >>> file_pos = fp.tell()
+ >>> incoming.run()
+ >>> len(pipeline_queue.files)
+ 0
+ >>> len(incoming_queue.files)
+ 0
+
+ >>> len(virgin_queue.files)
+ 1
+ >>> item = get_queue_messages('virgin')[0]
+ >>> print item.msg.as_string()
+ Subject: My first post
+ From: _xtest-owner@example.com
+ To: aperson@example.com
+ ...
+ <BLANKLINE>
+ --===============...
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ <BLANKLINE>
+ [No bounce details are available]
+ --===============...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+ <BLANKLINE>
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID: <first>
+ Date: ...
+ <BLANKLINE>
+ First post!
+ <BLANKLINE>
+ --===============...
+
+ >>> sorted(item.msgdata.items())
+ [...('recips', [u'aperson@example.com'])...]
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... REJECT: <first>
+ <BLANKLINE>
+
+ >>> del config.chains['always-reject']
diff --git a/src/mailman/queue/docs/lmtp.txt b/src/mailman/queue/docs/lmtp.txt
new file mode 100644
index 000000000..75e91fd4e
--- /dev/null
+++ b/src/mailman/queue/docs/lmtp.txt
@@ -0,0 +1,103 @@
+LTMP server
+===========
+
+Mailman can accept messages via LMTP (RFC 2033). Most modern mail servers
+support LMTP local delivery, so this is a very portable way to connect Mailman
+with your mail server.
+
+Our LMTP server is fairly simple though; all it does is make sure that the
+message is destined for a valid endpoint, e.g. mylist-join@example.com.
+
+Let's start a testable LMTP queue runner.
+
+ >>> from mailman.testing import helpers
+ >>> master = helpers.TestableMaster()
+ >>> master.start('lmtp')
+
+It also helps to have a nice LMTP client.
+
+ >>> lmtp = helpers.get_lmtp_client()
+ (220, '... Python LMTP queue runner 1.0')
+ >>> lmtp.lhlo('remote.example.org')
+ (250, ...)
+
+
+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.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> create_list(u'mylist@example.com')
+ <mailing list "mylist@example.com" at ...>
+ >>> commit()
+ >>> lmtp.sendmail(
+ ... 'anne.person@example.com',
+ ... ['mylist@example.com'], """\
+ ... From: anne.person@example.com
+ ... To: mylist@example.com
+ ... Subject: An interesting message
+ ... Message-ID: <badger>
+ ...
+ ... This is an interesting message.
+ ... """)
+ {}
+
+
+Sub-addresses
+-------------
+
+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.
+
+ >>> lmtp.sendmail(
+ ... 'anne.person@example.com',
+ ... ['mylist-request@example.com'], """\
+ ... From: anne.person@example.com
+ ... To: mylist-request@example.com
+ ... Subject: Help
+ ... Message-ID: <dog>
+ ...
+ ... Please help me.
+ ... """)
+ {}
+
+
+Clean up
+--------
+
+ >>> master.stop()
diff --git a/src/mailman/queue/docs/news.txt b/src/mailman/queue/docs/news.txt
new file mode 100644
index 000000000..3375b3d54
--- /dev/null
+++ b/src/mailman/queue/docs/news.txt
@@ -0,0 +1,157 @@
+The news runner
+===============
+
+The news runner is the queue runner that gateways mailing list messages to an
+NNTP newsgroup. One of the most important things this runner does is prepare
+the message for Usenet (yes, I know that NNTP is not Usenet, but this runner
+was originally written to gate to Usenet, which has its own rules).
+
+ >>> from mailman.queue.news import prepare_message
+ >>> mlist = config.db.list_manager.create(u'_xtest@example.com')
+ >>> mlist.linked_newsgroup = u'comp.lang.python'
+
+Some NNTP servers such as INN reject messages containing a set of prohibited
+headers, so one of the things that the news runner does is remove these
+prohibited headers.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... NNTP-Posting-Host: news.example.com
+ ... NNTP-Posting-Date: today
+ ... X-Trace: blah blah
+ ... X-Complaints-To: abuse@dom.ain
+ ... Xref: blah blah
+ ... Xref: blah blah
+ ... Date-Received: yesterday
+ ... Posted: tomorrow
+ ... Posting-Version: 99.99
+ ... Relay-Version: 88.88
+ ... Received: blah blah
+ ...
+ ... A message
+ ... """)
+ >>> msgdata = {}
+ >>> prepare_message(mlist, msg, msgdata)
+ >>> msgdata['prepped']
+ True
+ >>> print msg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ Newsgroups: comp.lang.python
+ Message-ID: ...
+ Lines: 1
+ <BLANKLINE>
+ A message
+ <BLANKLINE>
+
+Some NNTP servers will reject messages where certain headers are duplicated,
+so the news runner must collapse or move these duplicate headers to an
+X-Original-* header that the news server doesn't care about.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... To: two@example.com
+ ... Cc: three@example.com
+ ... Cc: four@example.com
+ ... Cc: five@example.com
+ ... Content-Transfer-Encoding: yes
+ ... Content-Transfer-Encoding: no
+ ... Content-Transfer-Encoding: maybe
+ ...
+ ... A message
+ ... """)
+ >>> msgdata = {}
+ >>> prepare_message(mlist, msg, msgdata)
+ >>> msgdata['prepped']
+ True
+ >>> print msg.as_string()
+ From: aperson@example.com
+ Newsgroups: comp.lang.python
+ Message-ID: ...
+ Lines: 1
+ To: _xtest@example.com
+ X-Original-To: two@example.com
+ CC: three@example.com
+ X-Original-CC: four@example.com
+ X-Original-CC: five@example.com
+ Content-Transfer-Encoding: yes
+ X-Original-Content-Transfer-Encoding: no
+ X-Original-Content-Transfer-Encoding: maybe
+ <BLANKLINE>
+ A message
+ <BLANKLINE>
+
+But if no headers are duplicated, then the news runner doesn't need to modify
+the message.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... Cc: someother@example.com
+ ... Content-Transfer-Encoding: yes
+ ...
+ ... A message
+ ... """)
+ >>> msgdata = {}
+ >>> prepare_message(mlist, msg, msgdata)
+ >>> msgdata['prepped']
+ True
+ >>> print msg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ Cc: someother@example.com
+ Content-Transfer-Encoding: yes
+ Newsgroups: comp.lang.python
+ Message-ID: ...
+ Lines: 1
+ <BLANKLINE>
+ A message
+ <BLANKLINE>
+
+
+Newsgroup moderation
+--------------------
+
+When the newsgroup is moderated, an Approved: header with the list's posting
+address is added for the benefit of the Usenet system.
+
+ >>> from mailman.interfaces import NewsModeration
+ >>> mlist.news_moderation = NewsModeration.open_moderated
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... Approved: this gets deleted
+ ...
+ ... """)
+ >>> prepare_message(mlist, msg, {})
+ >>> msg['approved']
+ u'_xtest@example.com'
+
+ >>> mlist.news_moderation = NewsModeration.moderated
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... Approved: this gets deleted
+ ...
+ ... """)
+ >>> prepare_message(mlist, msg, {})
+ >>> msg['approved']
+ u'_xtest@example.com'
+
+But if the newsgroup is not moderated, the Approved: header is not chnaged.
+
+ >>> mlist.news_moderation = NewsModeration.none
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... Approved: this doesn't get deleted
+ ...
+ ... """)
+ >>> prepare_message(mlist, msg, {})
+ >>> msg['approved']
+ u"this doesn't get deleted"
+
+
+XXX More of the NewsRunner should be tested.
diff --git a/src/mailman/queue/docs/outgoing.txt b/src/mailman/queue/docs/outgoing.txt
new file mode 100644
index 000000000..6722dee84
--- /dev/null
+++ b/src/mailman/queue/docs/outgoing.txt
@@ -0,0 +1,75 @@
+Outgoing queue runner
+=====================
+
+The outgoing queue runner is the process that delivers messages to the
+directly upstream SMTP server. It is this external SMTP server that performs
+final delivery to the intended recipients.
+
+Messages that appear in the outgoing queue are processed individually through
+a 'delivery module', essentially a pluggable interface for determining how the
+recipient set will be batched, whether messages will be personalized and
+VERP'd, etc. The outgoing runner doesn't itself support retrying but it can
+move messages to the 'retry queue' for handling delivery failures.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'test@example.com')
+
+ >>> from mailman.app.membership import add_member
+ >>> from mailman.interfaces.member import DeliveryMode
+ >>> add_member(mlist, u'aperson@example.com', u'Anne Person',
+ ... u'password', DeliveryMode.regular, u'en')
+ >>> add_member(mlist, u'bperson@example.com', u'Bart Person',
+ ... u'password', DeliveryMode.regular, u'en')
+ >>> add_member(mlist, u'cperson@example.com', u'Cris Person',
+ ... u'password', DeliveryMode.regular, u'en')
+
+By setting the mailing list to personalize messages, each recipient will get a
+unique copy of the message, with certain headers tailored for that recipient.
+
+ >>> from mailman.interfaces.mailinglist import Personalization
+ >>> mlist.personalize = Personalization.individual
+ >>> commit()
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: test@example.com
+ ... Subject: My first post
+ ... Message-ID: <first>
+ ...
+ ... First post!
+ ... """)
+
+Normally, messages would show up in the outgoing queue after the message has
+been processed by the rule set and pipeline. But we can simulate that here by
+injecting a message directly into the outgoing queue.
+
+ >>> msgdata = {}
+ >>> handler = config.handlers['calculate-recipients']
+ >>> handler.process(mlist, msg, msgdata)
+
+ >>> outgoing_queue = config.switchboards['out']
+ >>> ignore = outgoing_queue.enqueue(
+ ... msg, msgdata,
+ ... verp=True, listname=mlist.fqdn_listname, tolist=True,
+ ... _plaintext=True)
+
+Running the outgoing queue runner processes the message, delivering it to the
+upstream SMTP, which happens to be our test server.
+
+ >>> from mailman.queue.outgoing import OutgoingRunner
+ >>> from mailman.testing.helpers import make_testable_runner
+ >>> outgoing = make_testable_runner(OutgoingRunner, 'out')
+ >>> outgoing.run()
+
+Three messages have been delivered to our SMTP server, one for each recipient.
+
+ >>> from operator import itemgetter
+ >>> messages = sorted(smtpd.messages, key=itemgetter('sender'))
+ >>> len(messages)
+ 3
+
+ >>> for message in messages:
+ ... print message['sender']
+ test-bounces+aperson=example.com@example.com
+ test-bounces+bperson=example.com@example.com
+ test-bounces+cperson=example.com@example.com
diff --git a/src/mailman/queue/docs/runner.txt b/src/mailman/queue/docs/runner.txt
new file mode 100644
index 000000000..d24a8334c
--- /dev/null
+++ b/src/mailman/queue/docs/runner.txt
@@ -0,0 +1,72 @@
+Queue runners
+=============
+
+The queue runners (qrunner) are the processes that move messages around the
+Mailman system. Each qrunner is responsible for a slice of the hash space in
+a queue directory. It processes all the files in its slice, sleeps a little
+while, then wakes up and runs through its queue files again.
+
+
+Basic architecture
+------------------
+
+The basic architecture of qrunner is implemented in the base class that all
+runners inherit from. This base class implements a .run() method that runs
+continuously in a loop until the .stop() method is called.
+
+ >>> mlist = config.db.list_manager.create(u'_xtest@example.com')
+ >>> mlist.preferred_language = u'en'
+
+Here is a very simple derived qrunner class. Queue runners use a
+configuration section in the configuration files to determine run
+characteristics, such as the queue directory to use. Here we push a
+configuration section for the test runner.
+
+ >>> config.push('test-runner', """
+ ... [qrunner.test]
+ ... max_restarts: 1
+ ... """)
+
+ >>> from mailman.queue import Runner
+ >>> class TestableRunner(Runner):
+ ... def _dispose(self, mlist, msg, msgdata):
+ ... self.msg = msg
+ ... self.msgdata = msgdata
+ ... return False
+ ...
+ ... def _do_periodic(self):
+ ... self.stop()
+ ...
+ ... def _snooze(self, filecnt):
+ ... return
+
+ >>> runner = TestableRunner('test')
+
+This qrunner doesn't do much except run once, storing the message and metadata
+on instance variables.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ...
+ ... A test message.
+ ... """)
+ >>> switchboard = config.switchboards['test']
+ >>> filebase = switchboard.enqueue(msg, listname=mlist.fqdn_listname,
+ ... foo='yes', bar='no')
+ >>> runner.run()
+ >>> print runner.msg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ <BLANKLINE>
+ A test message.
+ <BLANKLINE>
+ >>> dump_msgdata(runner.msgdata)
+ _parsemsg: False
+ bar : no
+ foo : yes
+ lang : en
+ listname : _xtest@example.com
+ version : 3
+
+XXX More of the Runner API should be tested.
diff --git a/src/mailman/queue/docs/switchboard.txt b/src/mailman/queue/docs/switchboard.txt
new file mode 100644
index 000000000..88ab6ea93
--- /dev/null
+++ b/src/mailman/queue/docs/switchboard.txt
@@ -0,0 +1,182 @@
+The switchboard
+===============
+
+The switchboard is subsystem that moves messages between queues. Each
+instance of a switchboard is responsible for one queue directory.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ...
+ ... A test message.
+ ... """)
+
+Create a switchboard by giving its queue directory.
+
+ >>> import os
+ >>> queue_directory = os.path.join(config.QUEUE_DIR, 'test')
+ >>> from mailman.queue import Switchboard
+ >>> switchboard = Switchboard(queue_directory)
+ >>> switchboard.queue_directory == queue_directory
+ True
+
+Here's a helper function for ensuring things work correctly.
+
+ >>> def check_qfiles(directory=None):
+ ... if directory is None:
+ ... directory = queue_directory
+ ... files = {}
+ ... for qfile in os.listdir(directory):
+ ... root, ext = os.path.splitext(qfile)
+ ... files[ext] = files.get(ext, 0) + 1
+ ... if len(files) == 0:
+ ... print 'empty'
+ ... for ext in sorted(files):
+ ... print '{0}: {1}'.format(ext, files[ext])
+
+
+Enqueing and dequeing
+---------------------
+
+The message can be enqueued with metadata specified in the passed in
+dictionary.
+
+ >>> filebase = switchboard.enqueue(msg)
+ >>> check_qfiles()
+ .pck: 1
+
+To read the contents of a queue file, dequeue it.
+
+ >>> msg, msgdata = switchboard.dequeue(filebase)
+ >>> print msg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ <BLANKLINE>
+ A test message.
+ <BLANKLINE>
+ >>> dump_msgdata(msgdata)
+ _parsemsg: False
+ version : 3
+ >>> check_qfiles()
+ .bak: 1
+
+To complete the dequeing process, removing all traces of the message file,
+finish it (without preservation).
+
+ >>> switchboard.finish(filebase)
+ >>> check_qfiles()
+ empty
+
+When enqueing a file, you can provide additional metadata keys by using
+keyword arguments.
+
+ >>> filebase = switchboard.enqueue(msg, {'foo': 1}, bar=2)
+ >>> msg, msgdata = switchboard.dequeue(filebase)
+ >>> switchboard.finish(filebase)
+ >>> dump_msgdata(msgdata)
+ _parsemsg: False
+ bar : 2
+ foo : 1
+ version : 3
+
+Keyword arguments override keys from the metadata dictionary.
+
+ >>> filebase = switchboard.enqueue(msg, {'foo': 1}, foo=2)
+ >>> msg, msgdata = switchboard.dequeue(filebase)
+ >>> switchboard.finish(filebase)
+ >>> dump_msgdata(msgdata)
+ _parsemsg: False
+ foo : 2
+ version : 3
+
+
+Iterating over files
+--------------------
+
+There are two ways to iterate over all the files in a switchboard's queue.
+Normally, queue files end in .pck (for 'pickle') and the easiest way to
+iterate over just these files is to use the .files attribute.
+
+ >>> filebase_1 = switchboard.enqueue(msg, foo=1)
+ >>> filebase_2 = switchboard.enqueue(msg, foo=2)
+ >>> filebase_3 = switchboard.enqueue(msg, foo=3)
+ >>> filebases = sorted((filebase_1, filebase_2, filebase_3))
+ >>> sorted(switchboard.files) == filebases
+ True
+ >>> check_qfiles()
+ .pck: 3
+
+You can also use the .get_files() method if you want to iterate over all the
+file bases for some other extension.
+
+ >>> for filebase in switchboard.get_files():
+ ... msg, msgdata = switchboard.dequeue(filebase)
+ >>> bakfiles = sorted(switchboard.get_files('.bak'))
+ >>> bakfiles == filebases
+ True
+ >>> check_qfiles()
+ .bak: 3
+ >>> for filebase in switchboard.get_files('.bak'):
+ ... switchboard.finish(filebase)
+ >>> check_qfiles()
+ empty
+
+
+Recovering files
+----------------
+
+Calling .dequeue() without calling .finish() leaves .bak backup files in
+place. These can be recovered when the switchboard is instantiated.
+
+ >>> filebase_1 = switchboard.enqueue(msg, foo=1)
+ >>> filebase_2 = switchboard.enqueue(msg, foo=2)
+ >>> filebase_3 = switchboard.enqueue(msg, foo=3)
+ >>> for filebase in switchboard.files:
+ ... msg, msgdata = switchboard.dequeue(filebase)
+ ... # Don't call .finish()
+ >>> check_qfiles()
+ .bak: 3
+ >>> switchboard_2 = Switchboard(queue_directory, recover=True)
+ >>> check_qfiles()
+ .pck: 3
+
+The files can be recovered explicitly.
+
+ >>> for filebase in switchboard.files:
+ ... msg, msgdata = switchboard.dequeue(filebase)
+ ... # Don't call .finish()
+ >>> check_qfiles()
+ .bak: 3
+ >>> switchboard.recover_backup_files()
+ >>> check_qfiles()
+ .pck: 3
+
+But the files will only be recovered at most three times before they are
+considered defective. In order to prevent mail bombs and loops, once this
+maximum is reached, the files will be preserved in the 'bad' queue.
+
+ >>> for filebase in switchboard.files:
+ ... msg, msgdata = switchboard.dequeue(filebase)
+ ... # Don't call .finish()
+ >>> check_qfiles()
+ .bak: 3
+ >>> switchboard.recover_backup_files()
+ >>> check_qfiles()
+ empty
+
+ >>> bad = config.switchboards['bad']
+ >>> check_qfiles(bad.queue_directory)
+ .psv: 3
+
+Clean up
+
+ >>> for file in os.listdir(bad.queue_directory):
+ ... os.remove(os.path.join(bad.queue_directory, file))
+ >>> check_qfiles(bad.queue_directory)
+ empty
+
+
+Queue slices
+------------
+
+XXX Add tests for queue slices.