diff options
Diffstat (limited to 'src/mailman/docs')
| -rw-r--r-- | src/mailman/docs/__init__.py | 0 | ||||
| -rw-r--r-- | src/mailman/docs/addresses.txt | 231 | ||||
| -rw-r--r-- | src/mailman/docs/archivers.txt | 184 | ||||
| -rw-r--r-- | src/mailman/docs/bounces.txt | 107 | ||||
| -rw-r--r-- | src/mailman/docs/chains.txt | 345 | ||||
| -rw-r--r-- | src/mailman/docs/domains.txt | 46 | ||||
| -rw-r--r-- | src/mailman/docs/languages.txt | 104 | ||||
| -rw-r--r-- | src/mailman/docs/lifecycle.txt | 136 | ||||
| -rw-r--r-- | src/mailman/docs/listmanager.txt | 88 | ||||
| -rw-r--r-- | src/mailman/docs/membership.txt | 230 | ||||
| -rw-r--r-- | src/mailman/docs/message.txt | 48 | ||||
| -rw-r--r-- | src/mailman/docs/messagestore.txt | 113 | ||||
| -rw-r--r-- | src/mailman/docs/mlist-addresses.txt | 76 | ||||
| -rw-r--r-- | src/mailman/docs/pending.txt | 94 | ||||
| -rw-r--r-- | src/mailman/docs/pipelines.txt | 186 | ||||
| -rw-r--r-- | src/mailman/docs/registration.txt | 362 | ||||
| -rw-r--r-- | src/mailman/docs/requests.txt | 883 | ||||
| -rw-r--r-- | src/mailman/docs/styles.txt | 156 | ||||
| -rw-r--r-- | src/mailman/docs/usermanager.txt | 124 | ||||
| -rw-r--r-- | src/mailman/docs/users.txt | 195 |
20 files changed, 3708 insertions, 0 deletions
diff --git a/src/mailman/docs/__init__.py b/src/mailman/docs/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/docs/__init__.py diff --git a/src/mailman/docs/addresses.txt b/src/mailman/docs/addresses.txt new file mode 100644 index 000000000..9eccb2673 --- /dev/null +++ b/src/mailman/docs/addresses.txt @@ -0,0 +1,231 @@ +Email addresses +=============== + +Addresses represent a text email address, along with some meta data about +those addresses, such as their registration date, and whether and when they've +been validated. Addresses may be linked to the users that Mailman knows +about. Addresses are subscribed to mailing lists though members. + + >>> usermgr = config.db.user_manager + + +Creating addresses +------------------ + +Addresses are created directly through the user manager, which starts out with +no addresses. + + >>> sorted(address.address for address in usermgr.addresses) + [] + +Creating an unlinked email address is straightforward. + + >>> address_1 = usermgr.create_address(u'aperson@example.com') + >>> sorted(address.address for address in usermgr.addresses) + [u'aperson@example.com'] + +However, such addresses have no real name. + + >>> address_1.real_name + u'' + +You can also create an email address object with a real name. + + >>> address_2 = usermgr.create_address( + ... u'bperson@example.com', u'Ben Person') + >>> sorted(address.address for address in usermgr.addresses) + [u'aperson@example.com', u'bperson@example.com'] + >>> sorted(address.real_name for address in usermgr.addresses) + [u'', u'Ben Person'] + +The str() of the address is the RFC 2822 preferred originator format, while +the repr() carries more information. + + >>> str(address_2) + 'Ben Person <bperson@example.com>' + >>> repr(address_2) + '<Address: Ben Person <bperson@example.com> [not verified] at 0x...>' + +You can assign real names to existing addresses. + + >>> address_1.real_name = u'Anne Person' + >>> sorted(address.real_name for address in usermgr.addresses) + [u'Anne Person', u'Ben Person'] + +These addresses are not linked to users, and can be seen by searching the user +manager for an associated user. + + >>> print usermgr.get_user(u'aperson@example.com') + None + >>> print usermgr.get_user(u'bperson@example.com') + None + +You can create email addresses that are linked to users by using a different +interface. + + >>> user_1 = usermgr.create_user(u'cperson@example.com', u'Claire Person') + >>> sorted(address.address for address in user_1.addresses) + [u'cperson@example.com'] + >>> sorted(address.address for address in usermgr.addresses) + [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] + >>> sorted(address.real_name for address in usermgr.addresses) + [u'Anne Person', u'Ben Person', u'Claire Person'] + +And now you can find the associated user. + + >>> print usermgr.get_user(u'aperson@example.com') + None + >>> print usermgr.get_user(u'bperson@example.com') + None + >>> usermgr.get_user(u'cperson@example.com') + <User "Claire Person" at ...> + + +Deleting addresses +------------------ + +You can remove an unlinked address from the user manager. + + >>> usermgr.delete_address(address_1) + >>> sorted(address.address for address in usermgr.addresses) + [u'bperson@example.com', u'cperson@example.com'] + >>> sorted(address.real_name for address in usermgr.addresses) + [u'Ben Person', u'Claire Person'] + +Deleting a linked address does not delete the user, but it does unlink the +address from the user. + + >>> sorted(address.address for address in user_1.addresses) + [u'cperson@example.com'] + >>> user_1.controls(u'cperson@example.com') + True + >>> address_3 = list(user_1.addresses)[0] + >>> usermgr.delete_address(address_3) + >>> sorted(address.address for address in user_1.addresses) + [] + >>> user_1.controls(u'cperson@example.com') + False + >>> sorted(address.address for address in usermgr.addresses) + [u'bperson@example.com'] + + +Registration and validation +--------------------------- + +Addresses have two dates, the date the address was registered on and the date +the address was validated on. Neither date is set by default. + + >>> address_4 = usermgr.create_address( + ... u'dperson@example.com', u'Dan Person') + >>> print address_4.registered_on + None + >>> print address_4.verified_on + None + +The registered date takes a Python datetime object. + + >>> from datetime import datetime + >>> address_4.registered_on = datetime(2007, 5, 8, 22, 54, 1) + >>> print address_4.registered_on + 2007-05-08 22:54:01 + >>> print address_4.verified_on + None + +And of course, you can also set the validation date. + + >>> address_4.verified_on = datetime(2007, 5, 13, 22, 54, 1) + >>> print address_4.registered_on + 2007-05-08 22:54:01 + >>> print address_4.verified_on + 2007-05-13 22:54:01 + + +Subscriptions +------------- + +Addresses get subscribed to mailing lists, not users. When the address is +subscribed, a role is specified. + + >>> address_5 = usermgr.create_address( + ... u'eperson@example.com', u'Elly Person') + >>> mlist = config.db.list_manager.create(u'_xtext@example.com') + >>> from mailman.interfaces.member import MemberRole + >>> address_5.subscribe(mlist, MemberRole.owner) + <Member: Elly Person <eperson@example.com> on + _xtext@example.com as MemberRole.owner> + >>> address_5.subscribe(mlist, MemberRole.member) + <Member: Elly Person <eperson@example.com> on + _xtext@example.com as MemberRole.member> + +Now Elly is both an owner and a member of the mailing list. + + >>> sorted(mlist.owners.members) + [<Member: Elly Person <eperson@example.com> on + _xtext@example.com as MemberRole.owner>] + >>> sorted(mlist.moderators.members) + [] + >>> sorted(mlist.administrators.members) + [<Member: Elly Person <eperson@example.com> on + _xtext@example.com as MemberRole.owner>] + >>> sorted(mlist.members.members) + [<Member: Elly Person <eperson@example.com> on + _xtext@example.com as MemberRole.member>] + >>> sorted(mlist.regular_members.members) + [<Member: Elly Person <eperson@example.com> on + _xtext@example.com as MemberRole.member>] + >>> sorted(mlist.digest_members.members) + [] + + +Case-preserved addresses +------------------------ + +Technically speaking, email addresses are case sensitive in the local part. +Mailman preserves the case of addresses and uses the case preserved version +when sending the user a message, but it treats addresses that are different in +case equivalently in all other situations. + + >>> address_6 = usermgr.create_address( + ... u'FPERSON@example.com', u'Frank Person') + +The str() of such an address prints the RFC 2822 preferred originator format +with the original case-preserved address. The repr() contains all the gory +details. + + >>> str(address_6) + 'Frank Person <FPERSON@example.com>' + >>> repr(address_6) + '<Address: Frank Person <FPERSON@example.com> [not verified] + key: fperson@example.com at 0x...>' + +Both the case-insensitive version of the address and the original +case-preserved version are available on attributes of the IAddress object. + + >>> address_6.address + u'fperson@example.com' + >>> address_6.original_address + u'FPERSON@example.com' + +Because addresses are case-insensitive for all other purposes, you cannot +create an address that differs only in case. + + >>> usermgr.create_address(u'fperson@example.com') + Traceback (most recent call last): + ... + ExistingAddressError: FPERSON@example.com + >>> usermgr.create_address(u'fperson@EXAMPLE.COM') + Traceback (most recent call last): + ... + ExistingAddressError: FPERSON@example.com + >>> usermgr.create_address(u'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. + + >>> usermgr.get_address(u'fperson@example.com').address + u'fperson@example.com' + >>> usermgr.get_address(u'FPERSON@example.com').address + u'fperson@example.com' diff --git a/src/mailman/docs/archivers.txt b/src/mailman/docs/archivers.txt new file mode 100644 index 000000000..ef36a25ac --- /dev/null +++ b/src/mailman/docs/archivers.txt @@ -0,0 +1,184 @@ +Archivers +========= + +Mailman supports pluggable archivers, and it comes with several default +archivers. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'test@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: test@example.com + ... Subject: An archived message + ... Message-ID: <12345> + ... + ... Here is an archived message. + ... """) + +Archivers support an interface which provides the RFC 2369 List-Archive +header, and one that provides a 'permalink' to the specific message object in +the archive. This latter is appropriate for the message footer or for the RFC +5064 Archived-At header. + +Pipermail does not support a permalink, so that interface returns None. +Mailman defines a draft spec for how list servers and archivers can +interoperate. + + >>> archivers = {} + >>> from operator import attrgetter + >>> for archiver in sorted(config.archivers, key=attrgetter('name')): + ... print archiver.name + ... print ' ', archiver.list_url(mlist) + ... print ' ', archiver.permalink(mlist, msg) + ... archivers[archiver.name] = archiver + mail-archive + http://go.mail-archive.dev/test%40example.com + http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + mhonarc + http://lists.example.com/.../test@example.com + http://lists.example.com/.../RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE + pipermail + http://www.example.com/pipermail/test@example.com + None + prototype + http://lists.example.com + http://lists.example.com/RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE + + +Sending the message to the archiver +----------------------------------- + +The archiver is also able to archive the message. + + >>> archivers['pipermail'].archive_message(mlist, msg) + + >>> import os + >>> from mailman.interfaces.archiver import IPipermailMailingList + >>> pckpath = os.path.join( + ... IPipermailMailingList(mlist).archive_dir(), + ... 'pipermail.pck') + >>> os.path.exists(pckpath) + True + +Note however that the prototype archiver can't archive messages. + + >>> archivers['prototype'].archive_message(mlist, msg) + Traceback (most recent call last): + ... + NotImplementedError + + +The Mail-Archive.com +-------------------- + +The Mail-Archive <http://www.mail-archive.com> is a public archiver that can +be used to archive message for free. Mailman comes with a plugin for this +archiver; by enabling it messages to public lists will get sent there +automatically. + + >>> archiver = archivers['mail-archive'] + >>> print archiver.list_url(mlist) + http://go.mail-archive.dev/test%40example.com + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + +To archive the message, the archiver actually mails the message to a special +address at the Mail-Archive. + + >>> archiver.archive_message(mlist, msg) + + >>> from mailman.queue.outgoing import OutgoingRunner + >>> from mailman.testing.helpers import make_testable_runner + >>> outgoing = make_testable_runner(OutgoingRunner, 'out') + >>> outgoing.run() + + >>> from operator import itemgetter + >>> messages = list(smtpd.messages) + >>> len(messages) + 1 + + >>> print messages[0].as_string() + From: aperson@example.org + To: test@example.com + Subject: An archived message + Message-ID: <12345> + X-Message-ID-Hash: ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Sender: test-bounces@example.com + Errors-To: test-bounces@example.com + X-Peer: 127.0.0.1:... + X-MailFrom: test-bounces@example.com + X-RcptTo: archive@mail-archive.dev + <BLANKLINE> + Here is an archived message. + _______________________________________________ + Test mailing list + test@example.com + http://lists.example.com/listinfo/test@example.com + + >>> smtpd.clear() + +However, if the mailing list is not public, the message will never be archived +at this service. + + >>> mlist.archive_private = True + >>> print archiver.list_url(mlist) + None + >>> print archiver.permalink(mlist, msg) + None + >>> archiver.archive_message(mlist, msg) + >>> list(smtpd.messages) + [] + +Additionally, this archiver can handle malformed Message-IDs. + + >>> mlist.archive_private = False + >>> del msg['message-id'] + >>> msg['Message-ID'] = '12345>' + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/bXvG32YzcDEIVDaDLaUSVQekfo8= + + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<12345' + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/9rockPrT1Mm-jOsLWS6_hseR_OY= + + >>> del msg['message-id'] + >>> msg['Message-ID'] = '12345' + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + + >>> del msg['message-id'] + >>> msg['Message-ID'] = ' 12345 ' + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + + +MHonArc +------- + +The MHonArc archiver <http://www.mhonarc.org> is also available. + + >>> archiver = archivers['mhonarc'] + >>> print archiver.name + mhonarc + +Messages sent to a local MHonArc instance are added to its archive via a +subprocess call. + + >>> archiver.archive_message(mlist, msg) + >>> archive_log = open(os.path.join(config.LOG_DIR, 'archiver')) + >>> try: + ... contents = archive_log.read() + ... finally: + ... archive_log.close() + >>> print 'LOG:', contents + LOG: ... /usr/bin/mhonarc -add + -dbfile /.../private/test@example.com.mbox/mhonarc.db + -outdir /.../mhonarc/test@example.com + -stderr /.../logs/mhonarc + -stdout /.../logs/mhonarc + -spammode -umask 022 + ... diff --git a/src/mailman/docs/bounces.txt b/src/mailman/docs/bounces.txt new file mode 100644 index 000000000..9e8bcd23b --- /dev/null +++ b/src/mailman/docs/bounces.txt @@ -0,0 +1,107 @@ +Bounces +======= + +An important feature of Mailman is automatic bounce process. + +XXX Many more converted tests go here. + + +Bounces, or message rejection +----------------------------- + +Mailman can also 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 = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist.preferred_language = u'en' + +Any message can be bounced. + + >>> msg = message_from_string("""\ + ... To: _xtest@example.com + ... From: aperson@example.com + ... Subject: Something important + ... + ... I sometimes say something important. + ... """) + +Bounce a message by passing in the original message, and an optional error +message. The bounced message ends up in the virgin queue, awaiting sending +to the original messageauthor. + + >>> switchboard = config.switchboards['virgin'] + >>> from mailman.app.bounces import bounce_message + >>> bounce_message(mlist, msg) + >>> len(switchboard.files) + 1 + >>> filebase = switchboard.files[0] + >>> qmsg, qmsgdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> print qmsg.as_string() + Subject: Something important + From: _xtest-owner@example.com + To: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="..." + Message-ID: ... + Date: ... + Precedence: bulk + <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> + To: _xtest@example.com + From: aperson@example.com + Subject: Something important + <BLANKLINE> + I sometimes say something important. + <BLANKLINE> + --...-- + +An error message can be given when the message is bounced, and this will be +included in the payload of the text/plain part. The error message must be +passed in as an instance of a RejectMessage exception. + + >>> from mailman.core.errors import RejectMessage + >>> error = RejectMessage("This wasn't very important after all.") + >>> bounce_message(mlist, msg, error) + >>> len(switchboard.files) + 1 + >>> filebase = switchboard.files[0] + >>> qmsg, qmsgdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> print qmsg.as_string() + Subject: Something important + From: _xtest-owner@example.com + To: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="..." + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + --... + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + <BLANKLINE> + This wasn't very important after all. + --... + Content-Type: message/rfc822 + MIME-Version: 1.0 + <BLANKLINE> + To: _xtest@example.com + From: aperson@example.com + Subject: Something important + <BLANKLINE> + I sometimes say something important. + <BLANKLINE> + --...-- diff --git a/src/mailman/docs/chains.txt b/src/mailman/docs/chains.txt new file mode 100644 index 000000000..b6e75e6e1 --- /dev/null +++ b/src/mailman/docs/chains.txt @@ -0,0 +1,345 @@ +Chains +====== + +When a new message comes into the system, Mailman uses a set of rule chains to +decide whether the message gets posted to the list, rejected, discarded, or +held for moderator approval. + +There are a number of built-in chains available that act as end-points in the +processing of messages. + + +The Discard chain +----------------- + +The Discard chain simply throws the message away. + + >>> from zope.interface.verify import verifyObject + >>> from mailman.interfaces.chain import IChain + >>> chain = config.chains['discard'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + discard + >>> print chain.description + Discard a message and stop processing. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'_xtest@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: My first post + ... Message-ID: <first> + ... + ... An important message. + ... """) + + >>> from mailman.core.chains import process + + # 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')) + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'discard') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <first> + <BLANKLINE> + + +The Reject chain +---------------- + +The Reject chain bounces the message back to the original sender, and logs +this action. + + >>> chain = config.chains['reject'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + reject + >>> print chain.description + Reject/bounce a message and stop processing. + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'reject') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... REJECT: <first> + +The bounce message is now sitting in the Virgin queue. + + >>> virginq = config.switchboards['virgin'] + >>> len(virginq.files) + 1 + >>> qmsg, qdata = virginq.dequeue(virginq.files[0]) + >>> print qmsg.as_string() + Subject: My first post + From: _xtest-owner@example.com + To: aperson@example.com + ... + [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> + <BLANKLINE> + An important message. + <BLANKLINE> + ... + + +The Hold Chain +-------------- + +The Hold chain places the message into the admin request database and +depending on the list's settings, sends a notification to both the original +sender and the list moderators. + + >>> chain = config.chains['hold'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + hold + >>> print chain.description + Hold a message and stop processing. + + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'hold') + >>> 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> + +There are now two messages in the Virgin queue, one to the list moderators and +one to the original author. + + >>> len(virginq.files) + 2 + >>> qfiles = [] + >>> for filebase in virginq.files: + ... qmsg, qdata = virginq.dequeue(filebase) + ... virginq.finish(filebase) + ... qfiles.append(qmsg) + >>> from operator import itemgetter + >>> qfiles.sort(key=itemgetter('to')) + +This message is addressed to the mailing list moderators. + + >>> print qfiles[0].as_string() + Subject: _xtest@example.com post from aperson@example.com requires approval + From: _xtest-owner@example.com + To: _xtest-owner@example.com + MIME-Version: 1.0 + ... + As list administrator, your authorization is requested for the + following mailing list posting: + <BLANKLINE> + List: _xtest@example.com + From: aperson@example.com + Subject: My first post + Reason: XXX + <BLANKLINE> + At your convenience, visit: + <BLANKLINE> + http://lists.example.com/admindb/_xtest@example.com + <BLANKLINE> + to approve or deny the request. + <BLANKLINE> + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + <BLANKLINE> + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + <BLANKLINE> + An important message. + <BLANKLINE> + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + <BLANKLINE> + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Subject: confirm ... + Sender: _xtest-request@example.com + From: _xtest-request@example.com + ... + <BLANKLINE> + If you reply to this message, keeping the Subject: header intact, + Mailman will discard the held message. Do this if the message is + spam. If you reply to this message and include an Approved: header + with the list password in it, the message will be approved for posting + to the list. The Approved: header can also appear in the first line + of the body of the reply. + ... + +This message is addressed to the sender of the message. + + >>> print qfiles[1].as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Your message to _xtest@example.com awaits moderator approval + From: _xtest-bounces@example.com + To: aperson@example.com + ... + Your mail to '_xtest@example.com' with the subject + <BLANKLINE> + My first post + <BLANKLINE> + Is being held until the list moderator can review it for approval. + <BLANKLINE> + The reason it is being held: + <BLANKLINE> + XXX + <BLANKLINE> + Either the message will get posted to the list, or you will receive + notification of the moderator's decision. If you would like to cancel + this posting, please visit the following URL: + <BLANKLINE> + http://lists.example.com/confirm/_xtest@example.com/... + <BLANKLINE> + <BLANKLINE> + +In addition, the pending database is holding the original messages, waiting +for them to be disposed of by the original author or the list moderators. The +database is essentially a dictionary, with the keys being the randomly +selected tokens included in the urls and the values being a 2-tuple where the +first item is a type code and the second item is a message id. + + >>> import re + >>> cookie = None + >>> for line in qfiles[1].get_payload().splitlines(): + ... mo = re.search('confirm/[^/]+/(?P<cookie>.*)$', line) + ... if mo: + ... cookie = mo.group('cookie') + ... break + >>> assert cookie is not None, 'No confirmation token found' + >>> data = config.db.pendings.confirm(cookie) + >>> sorted(data.items()) + [(u'id', ...), (u'type', u'held message')] + +The message itself is held in the message store. + + >>> rkey, rdata = config.db.requests.get_list_requests(mlist).get_request( + ... data['id']) + >>> msg = config.db.message_store.get_message_by_id( + ... rdata['_mod_message_id']) + >>> print msg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + <BLANKLINE> + An important message. + <BLANKLINE> + + +The Accept chain +---------------- + +The Accept chain sends the message on the 'prep' queue, where it will be +processed and sent on to the list membership. + + >>> chain = config.chains['accept'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + accept + >>> print chain.description + Accept a message. + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'accept') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... ACCEPT: <first> + + >>> pipelineq = config.switchboards['pipeline'] + >>> len(pipelineq.files) + 1 + >>> qmsg, qdata = pipelineq.dequeue(pipelineq.files[0]) + >>> print qmsg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + <BLANKLINE> + An important message. + <BLANKLINE> + + +Run-time chains +--------------- + +We can also define chains at run time, and these chains can be mutated. +Run-time chains are made up of links where each link associates both a rule +and a 'jump'. The rule is really a rule name, which is looked up when +needed. The jump names a chain which is jumped to if the rule matches. + +There is one built-in run-time chain, called appropriately 'built-in'. This +is the default chain to use when no other input chain is defined for a mailing +list. It runs through the default rules, providing functionality similar to +the Hold handler from previous versions of Mailman. + + >>> chain = config.chains['built-in'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + built-in + >>> print chain.description + The built-in moderation chain. + +The previously created message is innocuous enough that it should pass through +all default rules. This message will end up in the pipeline queue. + + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}) + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... ACCEPT: <first> + + >>> qmsg, qdata = pipelineq.dequeue(pipelineq.files[0]) + >>> print qmsg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + X-Mailman-Rule-Misses: approved; emergency; loop; administrivia; + implicit-dest; + max-recipients; max-size; news-moderation; no-subject; + suspicious-header + <BLANKLINE> + An important message. + <BLANKLINE> + +In addition, the message metadata now contains lists of all rules that have +hit and all rules that have missed. + + >>> sorted(qdata['rule_hits']) + [] + >>> for rule_name in sorted(qdata['rule_misses']): + ... print rule_name + administrivia + approved + emergency + implicit-dest + loop + max-recipients + max-size + news-moderation + no-subject + suspicious-header diff --git a/src/mailman/docs/domains.txt b/src/mailman/docs/domains.txt new file mode 100644 index 000000000..b71689520 --- /dev/null +++ b/src/mailman/docs/domains.txt @@ -0,0 +1,46 @@ +Domains +======= + +Domains are how Mailman interacts with email host names and web host names. +Generally, new domains are registered in the mailman.cfg configuration file. +We simulate that here by pushing new configurations. + + >>> config.push('example.org', """ + ... [domain.example_dot_org] + ... email_host: example.org + ... base_url: https://mail.example.org + ... description: The example domain + ... contact_address: postmaster@mail.example.org + ... """) + + >>> domain = config.domains['example.org'] + >>> print domain.email_host + example.org + >>> print domain.base_url + https://mail.example.org + >>> print domain.description + The example domain + >>> print domain.contact_address + postmaster@mail.example.org + >>> print domain.url_host + mail.example.org + + +Confirmation tokens +------------------- + +Confirmation tokens can be added to either the email confirmation address... + + >>> print domain.confirm_address('xyz') + confirm-xyz@example.org + +...or the confirmation url. + + >>> print domain.confirm_url('abc') + https://mail.example.org/confirm/abc + + +Clean up +-------- + + >>> config.pop('example.org') diff --git a/src/mailman/docs/languages.txt b/src/mailman/docs/languages.txt new file mode 100644 index 000000000..775b933e8 --- /dev/null +++ b/src/mailman/docs/languages.txt @@ -0,0 +1,104 @@ +Languages +========= + +Mailman is multilingual. A language manager handles the known set of +languages at run time, as well as enabling those languages for use in a +running Mailman instance. + + >>> from zope.interface.verify import verifyObject + >>> from mailman.interfaces.languages import ILanguageManager + >>> from mailman.languages import LanguageManager + >>> mgr = LanguageManager() + >>> verifyObject(ILanguageManager, mgr) + True + +A language manager keeps track of the languages it knows about as well as the +languages which are enabled. By default, none are known or enabled. + + >>> sorted(mgr.known_codes) + [] + >>> sorted(mgr.enabled_codes) + [] + +The language manager also keeps track of information for each known language, +but you obviously can't get information for an unknown language. + + >>> mgr.get_description('en') + Traceback (most recent call last): + ... + KeyError: 'en' + >>> mgr.get_charset('en') + Traceback (most recent call last): + ... + KeyError: 'en' + + +Adding languages +---------------- + +Adding a new language requires three pieces of information, the 2-character +language code, the English description of the language, and the character set +used by the language. + + >>> mgr.add_language('en', 'English', 'us-ascii') + >>> mgr.add_language('it', 'Italian', 'iso-8859-1') + +By default, added languages are also enabled. + + >>> sorted(mgr.known_codes) + ['en', 'it'] + >>> sorted(mgr.enabled_codes) + ['en', 'it'] + +And you can get information for all known languages. + + >>> mgr.get_description('en') + 'English' + >>> mgr.get_charset('en') + 'us-ascii' + >>> mgr.get_description('it') + 'Italian' + >>> mgr.get_charset('it') + 'iso-8859-1' + +You can also add a language without enabling it. + + >>> mgr.add_language('pl', 'Polish', 'iso-8859-2', enable=False) + >>> sorted(mgr.known_codes) + ['en', 'it', 'pl'] + >>> sorted(mgr.enabled_codes) + ['en', 'it'] + +You can get language data for disabled languages. + + >>> mgr.get_description('pl') + 'Polish' + >>> mgr.get_charset('pl') + 'iso-8859-2' + +And of course you can enable a known language. + + >>> mgr.enable_language('pl') + >>> sorted(mgr.enabled_codes) + ['en', 'it', 'pl'] + +But you cannot enable languages that the manager does not know about. + + >>> mgr.enable_language('xx') + Traceback (most recent call last): + ... + KeyError: 'xx' + + +Other iterations +---------------- + +You can iterate over the descriptions (names) of all enabled languages. + + >>> sorted(mgr.enabled_names) + ['English', 'Italian', 'Polish'] + +You can ask whether a particular language code is enabled. + + >>> 'it' in mgr.enabled_codes + True diff --git a/src/mailman/docs/lifecycle.txt b/src/mailman/docs/lifecycle.txt new file mode 100644 index 000000000..c6c0c0671 --- /dev/null +++ b/src/mailman/docs/lifecycle.txt @@ -0,0 +1,136 @@ +Application level list lifecycle +-------------------------------- + +The low-level way to create and delete a mailing list is to use the +IListManager interface. This interface simply adds or removes the appropriate +database entries to record the list's creation. + +There is a higher level interface for creating and deleting mailing lists +which performs additional tasks such as: + + * validating the list's posting address (which also serves as the list's + fully qualified name); + * ensuring that the list's domain is registered; + * applying all matching styles to the new list; + * creating and assigning list owners; + * notifying watchers of list creation; + * creating ancillary artifacts (such as the list's on-disk directory) + + >>> from mailman.app.lifecycle import create_list + + +Posting address validation +-------------------------- + +If you try to use the higher-level interface to create a mailing list with a +bogus posting address, you get an exception. + + >>> create_list('not a valid address') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'not a valid address' + +If the posting address is valid, but the domain has not been registered with +Mailman yet, you get an exception. + + >>> create_list('test@example.org') + Traceback (most recent call last): + ... + BadDomainSpecificationError: example.org + + +Creating a list applies its styles +---------------------------------- + +Start by registering a test style. + + >>> from zope.interface import implements + >>> from mailman.interfaces.styles import IStyle + >>> class TestStyle(object): + ... implements(IStyle) + ... name = 'test' + ... priority = 10 + ... def apply(self, mailing_list): + ... # Just does something very simple. + ... mailing_list.msg_footer = u'test footer' + ... def match(self, mailing_list, styles): + ... # Applies to any test list + ... if 'test' in mailing_list.fqdn_listname: + ... styles.append(self) + + >>> config.style_manager.register(TestStyle()) + +Using the higher level interface for creating a list, applies all matching +list styles. + + >>> mlist_1 = create_list(u'test_1@example.com') + >>> mlist_1.fqdn_listname + u'test_1@example.com' + >>> mlist_1.msg_footer + u'test footer' + + +Creating a list with owners +--------------------------- + +You can also specify a list of owner email addresses. If these addresses are +not yet known, they will be registered, and new users will be linked to them. +However the addresses are not verified. + + >>> owners = [u'aperson@example.com', u'bperson@example.com', + ... u'cperson@example.com', u'dperson@example.com'] + >>> mlist_2 = create_list(u'test_2@example.com', owners) + >>> mlist_2.fqdn_listname + u'test_2@example.com' + >>> mlist_2.msg_footer + u'test footer' + >>> sorted(addr.address for addr in mlist_2.owners.addresses) + [u'aperson@example.com', u'bperson@example.com', + u'cperson@example.com', u'dperson@example.com'] + +None of the owner addresses are verified. + + >>> any(addr.verified_on is not None for addr in mlist_2.owners.addresses) + False + +However, all addresses are linked to users. + + >>> # The owners have no names yet + >>> len(list(mlist_2.owners.users)) + 4 + +If you create a mailing list with owner addresses that are already known to +the system, they won't be created again. + + >>> usermgr = config.db.user_manager + >>> user_a = usermgr.get_user(u'aperson@example.com') + >>> user_b = usermgr.get_user(u'bperson@example.com') + >>> user_c = usermgr.get_user(u'cperson@example.com') + >>> user_d = usermgr.get_user(u'dperson@example.com') + >>> user_a.real_name = u'Anne Person' + >>> user_b.real_name = u'Bart Person' + >>> user_c.real_name = u'Caty Person' + >>> user_d.real_name = u'Dirk Person' + + >>> mlist_3 = create_list(u'test_3@example.com', owners) + >>> sorted(user.real_name for user in mlist_3.owners.users) + [u'Anne Person', u'Bart Person', u'Caty Person', u'Dirk Person'] + + +Removing a list +--------------- + +Removing a mailing list deletes the list, all its subscribers, and any related +artifacts. + + >>> from mailman.app.lifecycle import remove_list + >>> remove_list(mlist_2.fqdn_listname, mlist_2, True) + >>> print config.db.list_manager.get('test_2@example.com') + None + +We should now be able to completely recreate the mailing list. + + >>> mlist_2a = create_list(u'test_2@example.com', owners) + >>> sorted(addr.address for addr in mlist_2a.owners.addresses) + [u'aperson@example.com', u'bperson@example.com', + u'cperson@example.com', u'dperson@example.com'] diff --git a/src/mailman/docs/listmanager.txt b/src/mailman/docs/listmanager.txt new file mode 100644 index 000000000..830f6d962 --- /dev/null +++ b/src/mailman/docs/listmanager.txt @@ -0,0 +1,88 @@ +Using the IListManager interface +================================ + +The IListManager is how you create, delete, and retrieve mailing list +objects. The Mailman system instantiates an IListManager for you based on the +configuration variable MANAGERS_INIT_FUNCTION. The instance is accessible +on the global config object. + + >>> from mailman.interfaces.listmanager import IListManager + >>> listmgr = config.db.list_manager + >>> IListManager.providedBy(listmgr) + True + + +Creating a mailing list +----------------------- + +Creating the list returns the newly created IMailList object. + + >>> from mailman.interfaces.mailinglist import IMailingList + >>> mlist = listmgr.create(u'_xtest@example.com') + >>> IMailingList.providedBy(mlist) + True + +All lists with identities have a short name, a host name, and a fully +qualified listname. This latter is what uniquely distinguishes the mailing +list to the system. + + >>> mlist.list_name + u'_xtest' + >>> mlist.host_name + u'example.com' + >>> mlist.fqdn_listname + u'_xtest@example.com' + +If you try to create a mailing list with the same name as an existing list, +you will get an exception. + + >>> mlist_dup = listmgr.create(u'_xtest@example.com') + Traceback (most recent call last): + ... + ListAlreadyExistsError: _xtest@example.com + + +Deleting a mailing list +----------------------- + +Use the list manager to delete a mailing list. + + >>> listmgr.delete(mlist) + >>> sorted(listmgr.names) + [] + +After deleting the list, you can create it again. + + >>> mlist = listmgr.create(u'_xtest@example.com') + >>> mlist.fqdn_listname + u'_xtest@example.com' + + +Retrieving a mailing list +------------------------- + +When a mailing list exists, you can ask the list manager for it and you will +always get the same object back. + + >>> mlist_2 = listmgr.get(u'_xtest@example.com') + >>> mlist_2 is mlist + True + +If you try to get a list that doesn't existing yet, you get None. + + >>> print listmgr.get(u'_xtest_2@example.com') + None + + +Iterating over all mailing lists +-------------------------------- + +Once you've created a bunch of mailing lists, you can use the list manager to +iterate over either the list objects, or the list names. + + >>> mlist_3 = listmgr.create(u'_xtest_3@example.com') + >>> mlist_4 = listmgr.create(u'_xtest_4@example.com') + >>> sorted(listmgr.names) + [u'_xtest@example.com', u'_xtest_3@example.com', u'_xtest_4@example.com'] + >>> sorted(m.fqdn_listname for m in listmgr.mailing_lists) + [u'_xtest@example.com', u'_xtest_3@example.com', u'_xtest_4@example.com'] diff --git a/src/mailman/docs/membership.txt b/src/mailman/docs/membership.txt new file mode 100644 index 000000000..7f9f16738 --- /dev/null +++ b/src/mailman/docs/membership.txt @@ -0,0 +1,230 @@ +List memberships +================ + +Users represent people in Mailman. Users control email addresses, and rosters +are collectons of members. A member gives an email address a role, such as +'member', 'administrator', or 'moderator'. 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. + +Mailing lists and roster sets have an indirect relationship, through the +roster set's name. Roster also have names, but are related to roster sets +by a more direct containment relationship. This is because it is possible to +store mailing list data in a different database than user data. + +When we create a mailing list, it starts out with no members... + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist + <mailing list "_xtest@example.com" at ...> + >>> sorted(member.address.address for member in mlist.members.members) + [] + >>> sorted(user.real_name for user in mlist.members.users) + [] + >>> sorted(address.address for member in mlist.members.addresses) + [] + +...no owners... + + >>> sorted(member.address.address for member in mlist.owners.members) + [] + >>> sorted(user.real_name for user in mlist.owners.users) + [] + >>> sorted(address.address for member in mlist.owners.addresses) + [] + +...no moderators... + + >>> sorted(member.address.address for member in mlist.moderators.members) + [] + >>> sorted(user.real_name for user in mlist.moderators.users) + [] + >>> sorted(address.address for member in mlist.moderators.addresses) + [] + +...and no administrators. + + >>> sorted(member.address.address + ... for member in mlist.administrators.members) + [] + >>> sorted(user.real_name for user in mlist.administrators.users) + [] + >>> sorted(address.address for member in mlist.administrators.addresses) + [] + + + +Administrators +-------------- + +A mailing list's administrators are defined as union of the list's owners and +the list's moderators. We can add new owners or moderators to this list by +assigning roles to users. First we have to create the user, because there are +no users in the user database yet. + + >>> usermgr = config.db.user_manager + >>> user_1 = usermgr.create_user(u'aperson@example.com', u'Anne Person') + >>> user_1.real_name + u'Anne Person' + >>> sorted(address.address for address in user_1.addresses) + [u'aperson@example.com'] + +We can add Anne as an owner of the mailing list, by creating a member role for +her. + + >>> from mailman.interfaces.member import MemberRole + >>> address_1 = list(user_1.addresses)[0] + >>> address_1.address + u'aperson@example.com' + >>> address_1.subscribe(mlist, MemberRole.owner) + <Member: Anne Person <aperson@example.com> on + _xtest@example.com as MemberRole.owner> + >>> sorted(member.address.address for member in mlist.owners.members) + [u'aperson@example.com'] + >>> sorted(user.real_name for user in mlist.owners.users) + [u'Anne Person'] + >>> sorted(address.address for address in mlist.owners.addresses) + [u'aperson@example.com'] + +Adding Anne as a list owner also makes her an administrator, but does not make +her a moderator. Nor does it make her a member of the list. + + >>> sorted(user.real_name for user in mlist.administrators.users) + [u'Anne Person'] + >>> sorted(user.real_name for user in mlist.moderators.users) + [] + >>> sorted(user.real_name for user in mlist.members.users) + [] + +We can add Ben as a moderator of the list, by creating a different member role +for him. + + >>> user_2 = usermgr.create_user(u'bperson@example.com', u'Ben Person') + >>> user_2.real_name + u'Ben Person' + >>> address_2 = list(user_2.addresses)[0] + >>> address_2.address + u'bperson@example.com' + >>> address_2.subscribe(mlist, MemberRole.moderator) + <Member: Ben Person <bperson@example.com> + on _xtest@example.com as MemberRole.moderator> + >>> sorted(member.address.address for member in mlist.moderators.members) + [u'bperson@example.com'] + >>> sorted(user.real_name for user in mlist.moderators.users) + [u'Ben Person'] + >>> sorted(address.address for address in mlist.moderators.addresses) + [u'bperson@example.com'] + +Now, both Anne and Ben are list administrators. + + >>> sorted(member.address.address + ... for member in mlist.administrators.members) + [u'aperson@example.com', u'bperson@example.com'] + >>> sorted(user.real_name for user in mlist.administrators.users) + [u'Anne Person', u'Ben Person'] + >>> sorted(address.address for address in mlist.administrators.addresses) + [u'aperson@example.com', u'bperson@example.com'] + + +Members +------- + +Similarly, list members are born of users being given the proper role. It's +more interesting here because these roles should have a preference which can +be used to decide whether the member is to get regular delivery or digest +delivery. Without a preference, Mailman will fall back first to the address's +preference, then the user's preference, then the list's preference. Start +without any member preference to see the system defaults. + + >>> user_3 = usermgr.create_user(u'cperson@example.com', u'Claire Person') + >>> user_3.real_name + u'Claire Person' + >>> address_3 = list(user_3.addresses)[0] + >>> address_3.address + u'cperson@example.com' + >>> address_3.subscribe(mlist, MemberRole.member) + <Member: Claire Person <cperson@example.com> + on _xtest@example.com as MemberRole.member> + +Claire will be a regular delivery member but not a digest member. + + >>> sorted(address.address for address in mlist.members.addresses) + [u'cperson@example.com'] + >>> sorted(address.address for address in mlist.regular_members.addresses) + [u'cperson@example.com'] + >>> sorted(address.address for address in mlist.digest_members.addresses) + [] + +It's easy to make the list administrators members of the mailing list too. + + >>> members = [] + >>> for address in mlist.administrators.addresses: + ... member = address.subscribe(mlist, MemberRole.member) + ... members.append(member) + >>> sorted(members, key=lambda m: m.address.address) + [<Member: Anne Person <aperson@example.com> on + _xtest@example.com as MemberRole.member>, + <Member: Ben Person <bperson@example.com> on + _xtest@example.com as MemberRole.member>] + >>> sorted(address.address for address in mlist.members.addresses) + [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] + >>> sorted(address.address for address in mlist.regular_members.addresses) + [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] + >>> sorted(address.address for address in mlist.digest_members.addresses) + [] + + +Finding members +--------------- + +You can find the IMember object that is a member of a roster for a given text +email address by using an IRoster's .get_member() method. + + >>> mlist.owners.get_member(u'aperson@example.com') + <Member: Anne Person <aperson@example.com> on + _xtest@example.com as MemberRole.owner> + >>> mlist.administrators.get_member(u'aperson@example.com') + <Member: Anne Person <aperson@example.com> on + _xtest@example.com as MemberRole.owner> + >>> mlist.members.get_member(u'aperson@example.com') + <Member: Anne Person <aperson@example.com> on + _xtest@example.com as MemberRole.member> + +However, if the address is not subscribed with the appropriate role, then None +is returned. + + >>> print mlist.administrators.get_member(u'zperson@example.com') + None + >>> print mlist.moderators.get_member(u'aperson@example.com') + None + >>> print mlist.members.get_member(u'zperson@example.com') + None + + +All subscribers +--------------- + +There is also a roster containing all the subscribers of a mailing list, +regardless of their role. + + >>> def sortkey(member): + ... return (member.address.address, int(member.role)) + >>> [(member.address.address, str(member.role)) + ... for member in sorted(mlist.subscribers.members, key=sortkey)] + [(u'aperson@example.com', 'MemberRole.member'), + (u'aperson@example.com', 'MemberRole.owner'), + (u'bperson@example.com', 'MemberRole.member'), + (u'bperson@example.com', 'MemberRole.moderator'), + (u'cperson@example.com', 'MemberRole.member')] + + +Double subscriptions +-------------------- + +It is an error to subscribe someone to a list with the same role twice. + + >>> address_1.subscribe(mlist, MemberRole.owner) + Traceback (most recent call last): + ... + AlreadySubscribedError: aperson@example.com is already a MemberRole.owner + of mailing list _xtest@example.com diff --git a/src/mailman/docs/message.txt b/src/mailman/docs/message.txt new file mode 100644 index 000000000..dab9ddf0e --- /dev/null +++ b/src/mailman/docs/message.txt @@ -0,0 +1,48 @@ +Messages +======== + +Mailman has its own Message classes, derived from the standard +email.message.Message class, but providing additional useful methods. + + +User notifications +------------------ + +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 = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist.preferred_language = u'en' + +The UserNotification constructor takes the recipient address, the sender +address, an optional subject, optional body text, and optional language. + + >>> from mailman.Message import UserNotification + >>> msg = UserNotification( + ... 'aperson@example.com', + ... '_xtest@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) + 1 + >>> filebase = switchboard.files[0] + >>> qmsg, qmsgdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> print qmsg.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 + To: aperson@example.com + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + I needed to tell you this. diff --git a/src/mailman/docs/messagestore.txt b/src/mailman/docs/messagestore.txt new file mode 100644 index 000000000..6e04568c5 --- /dev/null +++ b/src/mailman/docs/messagestore.txt @@ -0,0 +1,113 @@ +The message store +================= + +The message store is a collection of messages keyed off of Message-ID and +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. + + >>> store = config.db.message_store + +If you try to add a message to the store which is missing the Message-ID +header, you will get an exception. + + >>> msg = message_from_string("""\ + ... Subject: An important message + ... + ... This message is very important. + ... """) + >>> 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>' + >>> store.add(msg) + 'AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35' + >>> print msg.as_string() + Subject: An important message + Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> + X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 + <BLANKLINE> + This message is very important. + <BLANKLINE> + + +Finding messages +---------------- + +There are several ways to find a message given either the Message-ID or +X-Message-ID-Hash headers. In either case, if no matching message is found, +None is returned. + + >>> print store.get_message_by_id(u'nothing') + None + >>> print store.get_message_by_hash(u'nothing') + None + +Given an existing Message-ID, the message can be found. + + >>> message = store.get_message_by_id(msg['message-id']) + >>> print message.as_string() + Subject: An important message + Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> + X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 + <BLANKLINE> + This message is very important. + <BLANKLINE> + +Similarly, we can find messages by the X-Message-ID-Hash: + + >>> message = store.get_message_by_hash(msg['x-message-id-hash']) + >>> print message.as_string() + Subject: An important message + Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> + X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 + <BLANKLINE> + This message is very important. + <BLANKLINE> + + +Iterating over all messages +--------------------------- + +The message store provides a means to iterate over all the messages it +contains. + + >>> messages = list(store.messages) + >>> len(messages) + 1 + >>> print messages[0].as_string() + Subject: An important message + Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> + X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 + <BLANKLINE> + This message is very important. + <BLANKLINE> + + +Deleting messages from the store +-------------------------------- + +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. + + >>> store.delete_message(u'nothing') + Traceback (most recent call last): + ... + LookupError: nothing + +But if you delete an existing message, it really gets deleted. + + >>> message_id = message['message-id'] + >>> store.delete_message(message_id) + >>> list(store.messages) + [] + >>> print store.get_message_by_id(message_id) + None + >>> print store.get_message_by_hash(message['x-message-id-hash']) + None diff --git a/src/mailman/docs/mlist-addresses.txt b/src/mailman/docs/mlist-addresses.txt new file mode 100644 index 000000000..75ec3df37 --- /dev/null +++ b/src/mailman/docs/mlist-addresses.txt @@ -0,0 +1,76 @@ +Mailing list addresses +====================== + +Every mailing list has a number of addresses which are publicly available. +These are defined in the IMailingListAddresses interface. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + +The posting address is where people send messages to be posted to the mailing +list. This is exactly the same as the fully qualified list name. + + >>> mlist.fqdn_listname + u'_xtest@example.com' + >>> mlist.posting_address + u'_xtest@example.com' + +Messages to the mailing list's 'no reply' address always get discarded without +prejudice. + + >>> mlist.no_reply_address + u'noreply@example.com' + +The mailing list's owner address reaches the human moderators. + + >>> mlist.owner_address + u'_xtest-owner@example.com' + +The request address goes to the list's email command robot. + + >>> mlist.request_address + u'_xtest-request@example.com' + +The bounces address accepts and processes all potential bounces. + + >>> mlist.bounces_address + u'_xtest-bounces@example.com' + +The join (a.k.a. subscribe) address is where someone can email to get added to +the mailing list. The subscribe alias is a synonym for join, but it's +deprecated. + + >>> mlist.join_address + u'_xtest-join@example.com' + >>> mlist.subscribe_address + u'_xtest-subscribe@example.com' + +The leave (a.k.a. unsubscribe) address is where someone can email to get added +to the mailing list. The unsubscribe alias is a synonym for leave, but it's +deprecated. + + >>> mlist.leave_address + u'_xtest-leave@example.com' + >>> mlist.unsubscribe_address + u'_xtest-unsubscribe@example.com' + + +Email confirmations +------------------- + +Email confirmation messages are sent when actions such as subscriptions need +to be confirmed. It requires that a cookie be provided, which will be +included in the local part of the email address. The exact format of this is +dependent on the VERP_CONFIRM_FORMAT configuration variable. + + >>> mlist.confirm_address('cookie') + u'_xtest-confirm+cookie@example.com' + >>> mlist.confirm_address('wookie') + u'_xtest-confirm+wookie@example.com' + + >>> config.push('test config', """ + ... [mta] + ... verp_confirm_format: $address---$cookie + ... """) + >>> mlist.confirm_address('cookie') + u'_xtest-confirm---cookie@example.com' + >>> config.pop('test config') diff --git a/src/mailman/docs/pending.txt b/src/mailman/docs/pending.txt new file mode 100644 index 000000000..abfba4885 --- /dev/null +++ b/src/mailman/docs/pending.txt @@ -0,0 +1,94 @@ +The pending database +==================== + +The pending database is where various types of events which need confirmation +are stored. These can include email address registration events, held +messages (but only for user confirmation), auto-approvals, and probe bounces. +This is not where messages held for administrator approval are kept. + + >>> from zope.interface import implements + >>> from zope.interface.verify import verifyObject + +In order to pend an event, you first need a pending database, which is +available by adapting the list manager. + + >>> from mailman.interfaces.pending import IPendings + >>> pendingdb = config.db.pendings + >>> verifyObject(IPendings, pendingdb) + True + +The pending database can add any IPendable to the database, returning a token +that can be used in urls and such. + + >>> from mailman.interfaces.pending import IPendable + >>> class SimplePendable(dict): + ... implements(IPendable) + >>> subscription = SimplePendable( + ... type='subscription', + ... address='aperson@example.com', + ... realname='Anne Person', + ... language='en', + ... password='xyz') + >>> token = pendingdb.add(subscription) + >>> 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 dict) from the +database that matches the token. If the token isn't in the database, None is +returned. + + >>> pendable = pendingdb.confirm('missing') + >>> print pendable + None + >>> pendable = pendingdb.confirm(token) + >>> sorted(pendable.items()) + [(u'address', u'aperson@example.com'), + (u'language', u'en'), + (u'password', u'xyz'), + (u'realname', u'Anne Person'), + (u'type', u'subscription')] + +After confirmation, the token is no longer in the database. + + >>> pendable = pendingdb.confirm(token) + >>> print pendable + None + +There are a few other things you can do with the pending database. When you +confirm a token, you can leave it in the database, or in otherwords, not +expunge it. + + >>> event_1 = SimplePendable(type='one') + >>> token_1 = pendingdb.add(event_1) + >>> event_2 = SimplePendable(type='two') + >>> token_2 = pendingdb.add(event_2) + >>> event_3 = SimplePendable(type='three') + >>> token_3 = pendingdb.add(event_3) + >>> pendable = pendingdb.confirm(token_1, expunge=False) + >>> pendable.items() + [(u'type', u'one')] + >>> pendable = pendingdb.confirm(token_1, expunge=True) + >>> pendable.items() + [(u'type', u'one')] + >>> pendable = pendingdb.confirm(token_1) + >>> print pendable + None + +An event can be given a lifetime when it is pended, otherwise it just uses a +default lifetime. + + >>> from datetime import timedelta + >>> yesterday = timedelta(days=-1) + >>> event_4 = SimplePendable(type='four') + >>> token_4 = pendingdb.add(event_4, lifetime=yesterday) + +Every once in a while the pending database is cleared of old records. + + >>> pendingdb.evict() + >>> pendable = pendingdb.confirm(token_4) + >>> print pendable + None + >>> pendable = pendingdb.confirm(token_2) + >>> pendable.items() + [(u'type', u'two')] diff --git a/src/mailman/docs/pipelines.txt b/src/mailman/docs/pipelines.txt new file mode 100644 index 000000000..0e6dad8e8 --- /dev/null +++ b/src/mailman/docs/pipelines.txt @@ -0,0 +1,186 @@ +Pipelines +========= + +This runner's purpose in life is to process messages that have been accepted +for posting, applying any modifications and also sending copies of the message +to the archives, digests, nntp, and outgoing queues. Pipelines are named and +consist of a sequence of handlers, each of which is applied in turn. Unlike +rules and chains, there is no way to stop a pipeline from processing the +message once it's started. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'xtest@example.com') + >>> print mlist.pipeline + built-in + >>> from mailman.core.pipelines import process + + +Processing a message +-------------------- + +Messages hit the pipeline after they've been accepted for posting. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: xtest@example.com + ... Subject: My first post + ... Message-ID: <first> + ... + ... First post! + ... """) + >>> msgdata = {} + >>> process(mlist, msg, msgdata, mlist.pipeline) + +The message has been modified with additional headers, footer decorations, +etc. + + >>> print msg.as_string() + From: aperson@example.com + To: xtest@example.com + Message-ID: <first> + Subject: [Xtest] My first post + X-BeenThere: xtest@example.com + X-Mailman-Version: ... + Precedence: list + List-Id: <xtest.example.com> + X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Post: <mailto:xtest@example.com> + List-Subscribe: + <http://lists.example.com/listinfo/xtest@example.com>, + <mailto:xtest-join@example.com> + Archived-At: + http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Unsubscribe: + <http://lists.example.com/listinfo/xtest@example.com>, + <mailto:xtest-leave@example.com> + List-Archive: <http://lists.example.com/archives/xtest@example.com> + List-Help: <mailto:xtest-request@example.com?subject=help> + <BLANKLINE> + First post! + <BLANKLINE> + +And the message metadata has information about recipients and other stuff. +However there are currently no recipients for this message. + + >>> dump_msgdata(msgdata) + original_sender : aperson@example.com + origsubj : My first post + recips : set([]) + stripped_subject: My first post + +And the message is now sitting in various other processing queues. + + >>> from mailman.testing.helpers import get_queue_messages + >>> messages = get_queue_messages('archive') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + From: aperson@example.com + To: xtest@example.com + Message-ID: <first> + Subject: [Xtest] My first post + X-BeenThere: xtest@example.com + X-Mailman-Version: ... + Precedence: list + List-Id: <xtest.example.com> + X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Post: <mailto:xtest@example.com> + List-Subscribe: + <http://lists.example.com/listinfo/xtest@example.com>, + <mailto:xtest-join@example.com> + Archived-At: + http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Unsubscribe: + <http://lists.example.com/listinfo/xtest@example.com>, + <mailto:xtest-leave@example.com> + List-Archive: <http://lists.example.com/archives/xtest@example.com> + List-Help: <mailto:xtest-request@example.com?subject=help> + <BLANKLINE> + First post! + <BLANKLINE> + >>> dump_msgdata(messages[0].msgdata) + _parsemsg : False + original_sender : aperson@example.com + origsubj : My first post + recips : set([]) + stripped_subject: My first post + version : 3 + +This mailing list is not linked to an NNTP newsgroup, so there's nothing in +the outgoing nntp queue. + + >>> messages = get_queue_messages('news') + >>> len(messages) + 0 + +This is the message that will actually get delivered to end recipients. + + >>> messages = get_queue_messages('out') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + From: aperson@example.com + To: xtest@example.com + Message-ID: <first> + Subject: [Xtest] My first post + X-BeenThere: xtest@example.com + X-Mailman-Version: ... + Precedence: list + List-Id: <xtest.example.com> + X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Post: <mailto:xtest@example.com> + List-Subscribe: + <http://lists.example.com/listinfo/xtest@example.com>, + <mailto:xtest-join@example.com> + Archived-At: + http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Unsubscribe: + <http://lists.example.com/listinfo/xtest@example.com>, + <mailto:xtest-leave@example.com> + List-Archive: <http://lists.example.com/archives/xtest@example.com> + List-Help: <mailto:xtest-request@example.com?subject=help> + <BLANKLINE> + First post! + <BLANKLINE> + >>> dump_msgdata(messages[0].msgdata) + _parsemsg : False + listname : xtest@example.com + original_sender : aperson@example.com + origsubj : My first post + recips : set([]) + stripped_subject: My first post + version : 3 + +There's now one message in the digest mailbox, getting ready to be sent. + + >>> from mailman.testing.helpers import digest_mbox + >>> digest = digest_mbox(mlist) + >>> sum(1 for mboxmsg in digest) + 1 + >>> print list(digest)[0].as_string() + From: aperson@example.com + To: xtest@example.com + Message-ID: <first> + Subject: [Xtest] My first post + X-BeenThere: xtest@example.com + X-Mailman-Version: ... + Precedence: list + List-Id: <xtest.example.com> + X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Post: <mailto:xtest@example.com> + List-Subscribe: + <http://lists.example.com/listinfo/xtest@example.com>, + <mailto:xtest-join@example.com> + Archived-At: + http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Unsubscribe: + <http://lists.example.com/listinfo/xtest@example.com>, + <mailto:xtest-leave@example.com> + List-Archive: <http://lists.example.com/archives/xtest@example.com> + List-Help: <mailto:xtest-request@example.com?subject=help> + <BLANKLINE> + First post! + <BLANKLINE> + <BLANKLINE> + + >>> digest.clear() diff --git a/src/mailman/docs/registration.txt b/src/mailman/docs/registration.txt new file mode 100644 index 000000000..d243188bc --- /dev/null +++ b/src/mailman/docs/registration.txt @@ -0,0 +1,362 @@ +Address registration +==================== + +When a user wants to join a mailing list -- any mailing list -- in the running +instance, he or she must first register with Mailman. The only thing they +must supply is an email address, although there is additional information they +may supply. All registered email addresses must be verified before Mailman +will send them any list traffic. + + >>> from mailman.app.registrar import Registrar + >>> from mailman.interfaces.registrar import IRegistrar + +The IUserManager manages users, but it does so at a fairly low level. +Specifically, it does not handle verifications, email address syntax validity +checks, etc. The IRegistrar is the interface to the object handling all this +stuff. + +Add a domain, which will provide the context for the verification email +message. + + >>> config.push('mail', """ + ... [domain.mail_example_dot_com] + ... email_host: mail.example.com + ... base_url: http://mail.example.com + ... contact_address: postmaster@mail.example.com + ... """) + + >>> domain = config.domains['mail.example.com'] + +Get a registrar by adapting a context to the interface. + + >>> from zope.interface.verify import verifyObject + >>> registrar = IRegistrar(domain) + >>> verifyObject(IRegistrar, registrar) + True + +Here is a helper function to check the token strings. + + >>> def check_token(token): + ... assert isinstance(token, basestring), 'Not a string' + ... assert len(token) == 40, 'Unexpected length: %d' % len(token) + ... assert token.isalnum(), 'Not alphanumeric' + ... print 'ok' + +Here is a helper function to extract tokens from confirmation messages. + + >>> import re + >>> cre = re.compile('http://mail.example.com/confirm/(.*)') + >>> def extract_token(msg): + ... mo = cre.search(qmsg.get_payload()) + ... return mo.group(1) + + +Invalid email addresses +----------------------- + +The only piece of information you need to register is the email address. +Some amount of sanity checks are performed on the email address, although +honestly, not as much as probably should be done. Still, some patently bad +addresses are rejected outright. + + >>> registrar.register('') + Traceback (most recent call last): + ... + InvalidEmailAddress: '' + >>> registrar.register('some name@example.com') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'some name@example.com' + >>> registrar.register('<script>@example.com') + Traceback (most recent call last): + ... + InvalidEmailAddress: '<script>@example.com' + >>> registrar.register('\xa0@example.com') + Traceback (most recent call last): + ... + InvalidEmailAddress: '\xa0@example.com' + >>> registrar.register('noatsign') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'noatsign' + >>> registrar.register('nodom@ain') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'nodom@ain' + + +Register an email address +------------------------- + +Registration of an unknown address creates nothing until the confirmation step +is complete. No IUser or IAddress is created at registration time, but a +record is added to the pending database, and the token for that record is +returned. + + >>> token = registrar.register(u'aperson@example.com', u'Anne Person') + >>> check_token(token) + ok + +There should be no records in the user manager for this address yet. + + >>> usermgr = config.db.user_manager + >>> print usermgr.get_user(u'aperson@example.com') + None + >>> print usermgr.get_address(u'aperson@example.com') + None + +But this address is waiting for confirmation. + + >>> pendingdb = config.db.pendings + >>> sorted(pendingdb.confirm(token, expunge=False).items()) + [(u'address', u'aperson@example.com'), + (u'real_name', u'Anne Person'), + (u'type', u'registration')] + + +Verification by email +--------------------- + +There is also a verification email sitting in the virgin queue now. This +message is sent to the user in order to verify the registered address. + + >>> switchboard = config.switchboards['virgin'] + >>> len(switchboard.files) + 1 + >>> filebase = switchboard.files[0] + >>> qmsg, qdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: confirm ... + From: confirm-...@mail.example.com + To: aperson@example.com + Message-ID: <...> + Date: ... + Precedence: bulk + <BLANKLINE> + Email Address Registration Confirmation + <BLANKLINE> + Hello, this is the GNU Mailman server at mail.example.com. + <BLANKLINE> + We have received a registration request for the email address + <BLANKLINE> + aperson@example.com + <BLANKLINE> + Before you can start using GNU Mailman at this site, you must first + confirm that this is your email address. You can do this by replying to + this message, keeping the Subject header intact. Or you can visit this + web page + <BLANKLINE> + http://mail.example.com/confirm/... + <BLANKLINE> + If you do not wish to register this email address simply disregard this + message. If you think you are being maliciously subscribed to the list, + or have any other questions, you may contact + <BLANKLINE> + postmaster@mail.example.com + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + nodecorate : True + recips : [u'aperson@example.com'] + reduced_list_headers: True + version : 3 + +The confirmation token shows up in several places, each of which provides an +easy way for the user to complete the confirmation. The token will always +appear in a URL in the body of the message. + + >>> sent_token = extract_token(qmsg) + >>> sent_token == token + True + +The same token will appear in the From header. + + >>> qmsg['from'] == 'confirm-' + token + '@mail.example.com' + True + +It will also appear in the Subject header. + + >>> qmsg['subject'] == 'confirm ' + token + True + +The user would then validate their just registered address by clicking on a +url or responding to the message. Either way, the confirmation process +extracts the token and uses that to confirm the pending registration. + + >>> registrar.confirm(token) + True + +Now, there is an IAddress in the database matching the address, as well as an +IUser linked to this address. The IAddress is verified. + + >>> found_address = usermgr.get_address(u'aperson@example.com') + >>> found_address + <Address: Anne Person <aperson@example.com> [verified] at ...> + >>> found_user = usermgr.get_user(u'aperson@example.com') + >>> found_user + <User "Anne Person" at ...> + >>> found_user.controls(found_address.address) + True + >>> from datetime import datetime + >>> isinstance(found_address.verified_on, datetime) + True + + +Non-standard registrations +-------------------------- + +If you try to confirm a registration token twice, of course only the first one +will work. The second one is ignored. + + >>> token = registrar.register(u'bperson@example.com') + >>> check_token(token) + ok + >>> filebase = switchboard.files[0] + >>> qmsg, qdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> sent_token = extract_token(qmsg) + >>> token == sent_token + True + >>> registrar.confirm(token) + True + >>> registrar.confirm(token) + False + +If an address is in the system, but that address is not linked to a user yet +and the address is not yet validated, then no user is created until the +confirmation step is completed. + + >>> usermgr.create_address(u'cperson@example.com') + <Address: cperson@example.com [not verified] at ...> + >>> token = registrar.register(u'cperson@example.com', u'Claire Person') + >>> print usermgr.get_user(u'cperson@example.com') + None + >>> filebase = switchboard.files[0] + >>> qmsg, qdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> registrar.confirm(token) + True + >>> usermgr.get_user(u'cperson@example.com') + <User "Claire Person" at ...> + >>> usermgr.get_address(u'cperson@example.com') + <Address: cperson@example.com [verified] at ...> + +Even if the address being registered has already been verified, the +registration sends a confirmation. + + >>> token = registrar.register(u'cperson@example.com') + >>> token is not None + True + + +Discarding +---------- + +A confirmation token can also be discarded, say if the user changes his or her +mind about registering. When discarded, no IAddress or IUser is created. + + >>> token = registrar.register(u'eperson@example.com', u'Elly Person') + >>> check_token(token) + ok + >>> registrar.discard(token) + >>> print pendingdb.confirm(token) + None + >>> print usermgr.get_address(u'eperson@example.com') + None + >>> print usermgr.get_user(u'eperson@example.com') + None + + +Registering a new address for an existing user +---------------------------------------------- + +When a new address for an existing user is registered, there isn't too much +different except that the new address will still need to be verified before it +can be used. + + >>> dperson = usermgr.create_user(u'dperson@example.com', u'Dave Person') + >>> dperson + <User "Dave Person" at ...> + >>> address = usermgr.get_address(u'dperson@example.com') + >>> address.verified_on = datetime.now() + + >>> from operator import attrgetter + >>> sorted((addr for addr in dperson.addresses), key=attrgetter('address')) + [<Address: Dave Person <dperson@example.com> [verified] at ...>] + >>> dperson.register(u'david.person@example.com', u'David Person') + <Address: David Person <david.person@example.com> [not verified] at ...> + >>> token = registrar.register(u'david.person@example.com') + >>> filebase = switchboard.files[0] + >>> qmsg, qdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> registrar.confirm(token) + True + >>> user = usermgr.get_user(u'david.person@example.com') + >>> user is dperson + True + >>> user + <User "Dave Person" at ...> + >>> sorted((addr for addr in user.addresses), key=attrgetter('address')) + [<Address: David Person <david.person@example.com> [verified] at ...>, + <Address: Dave Person <dperson@example.com> [verified] at ...>] + + +Corner cases +------------ + +If you try to confirm a token that doesn't exist in the pending database, the +confirm method will just return None. + + >>> registrar.confirm('no token') + False + +Likewise, if you try to confirm, through the IUserRegistrar interface, a token +that doesn't match a registration even, you will get None. However, the +pending even matched with that token will still be removed. + + >>> from mailman.interfaces.pending import IPendable + >>> from zope.interface import implements + + >>> class SimplePendable(dict): + ... implements(IPendable) + >>> pendable = SimplePendable(type='foo', bar='baz') + >>> token = pendingdb.add(pendable) + >>> registrar.confirm(token) + False + >>> print pendingdb.confirm(token) + None + + +Registration and subscription +----------------------------- + +Fred registers with Mailman at the same time that he subscribes to a mailing +list. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'alpha@example.com') + >>> token = registrar.register( + ... u'fred.person@example.com', 'Fred Person', mlist) + +Before confirmation, Fred is not a member of the mailing list. + + >>> print mlist.members.get_member(u'fred.person@example.com') + None + +But after confirmation, he is. + + >>> registrar.confirm(token) + True + >>> print mlist.members.get_member(u'fred.person@example.com') + <Member: Fred Person <fred.person@example.com> + on alpha@example.com as MemberRole.member> + + +Clean up +-------- + + >>> config.pop('mail') diff --git a/src/mailman/docs/requests.txt b/src/mailman/docs/requests.txt new file mode 100644 index 000000000..87b835fb8 --- /dev/null +++ b/src/mailman/docs/requests.txt @@ -0,0 +1,883 @@ +Moderator requests +================== + +Various actions will be held for moderator approval, such as subscriptions to +closed lists, or postings by non-members. The requests database is the low +level interface to these actions requiring approval. + +Here is a helper function for printing out held requests. + + >>> def show_holds(requests): + ... for request in requests.held_requests: + ... key, data = requests.get_request(request.id) + ... print request.id, str(request.request_type), key + ... if data is not None: + ... for key in sorted(data): + ... print ' {0}: {1}'.format(key, data[key]) + +And another helper for displaying messages in the virgin queue. + + >>> virginq = config.switchboards['virgin'] + >>> def dequeue(whichq=None, expected_count=1): + ... if whichq is None: + ... whichq = virginq + ... assert len(whichq.files) == expected_count, ( + ... 'Unexpected file count: %d' % len(whichq.files)) + ... filebase = whichq.files[0] + ... qmsg, qdata = whichq.dequeue(filebase) + ... whichq.finish(filebase) + ... return qmsg, qdata + + +Mailing list centric +-------------------- + +A set of requests are always related to a particular mailing list, so given a +mailing list you need to get its requests object. + + >>> from mailman.interfaces.requests import IListRequests, IRequests + >>> from zope.interface.verify import verifyObject + >>> verifyObject(IRequests, config.db.requests) + True + >>> mlist = config.db.list_manager.create(u'test@example.com') + >>> requests = config.db.requests.get_list_requests(mlist) + >>> verifyObject(IListRequests, requests) + True + >>> requests.mailing_list + <mailing list "test@example.com" at ...> + + +Holding requests +---------------- + +The list's requests database starts out empty. + + >>> requests.count + 0 + >>> list(requests.held_requests) + [] + +At the lowest level, the requests database is very simple. Holding a request +requires a request type (as an enum value), a key, and an optional dictionary +of associated data. The request database assigns no semantics to the held +data, except for the request type. Here we hold some simple bits of data. + + >>> from mailman.interfaces.requests import RequestType + >>> id_1 = requests.hold_request(RequestType.held_message, u'hold_1') + >>> id_2 = requests.hold_request(RequestType.subscription, u'hold_2') + >>> id_3 = requests.hold_request(RequestType.unsubscription, u'hold_3') + >>> id_4 = requests.hold_request(RequestType.held_message, u'hold_4') + >>> id_1, id_2, id_3, id_4 + (1, 2, 3, 4) + +And of course, now we can see that there are four requests being held. + + >>> requests.count + 4 + >>> requests.count_of(RequestType.held_message) + 2 + >>> requests.count_of(RequestType.subscription) + 1 + >>> requests.count_of(RequestType.unsubscription) + 1 + >>> show_holds(requests) + 1 RequestType.held_message hold_1 + 2 RequestType.subscription hold_2 + 3 RequestType.unsubscription hold_3 + 4 RequestType.held_message hold_4 + +If we try to hold a request with a bogus type, we get an exception. + + >>> requests.hold_request(5, 'foo') + Traceback (most recent call last): + ... + TypeError: 5 + +We can hold requests with additional data. + + >>> data = dict(foo='yes', bar='no') + >>> id_5 = requests.hold_request(RequestType.held_message, u'hold_5', data) + >>> id_5 + 5 + >>> requests.count + 5 + >>> show_holds(requests) + 1 RequestType.held_message hold_1 + 2 RequestType.subscription hold_2 + 3 RequestType.unsubscription hold_3 + 4 RequestType.held_message hold_4 + 5 RequestType.held_message hold_5 + bar: no + foo: yes + + +Getting requests +---------------- + +We can ask the requests database for a specific request, by providing the id +of the request data we want. This returns a 2-tuple of the key and data we +originally held. + + >>> key, data = requests.get_request(2) + >>> print key + hold_2 + +Because we did not store additional data with request 2, it comes back as None +now. + + >>> print data + None + +However, if we ask for a request that had data, we'd get it back now. + + >>> key, data = requests.get_request(5) + >>> print key + hold_5 + >>> dump_msgdata(data) + bar: no + foo: yes + +If we ask for a request that is not in the database, we get None back. + + >>> print requests.get_request(801) + None + + +Iterating over requests +----------------------- + +To make it easier to find specific requests, the list requests can be iterated +over by type. + + >>> requests.count_of(RequestType.held_message) + 3 + >>> for request in requests.of_type(RequestType.held_message): + ... assert request.request_type is RequestType.held_message + ... key, data = requests.get_request(request.id) + ... print request.id, key + ... if data is not None: + ... for key in sorted(data): + ... print ' {0}: {1}'.format(key, data[key]) + 1 hold_1 + 4 hold_4 + 5 hold_5 + bar: no + foo: yes + + +Deleting requests +----------------- + +Once a specific request has been handled, it will be deleted from the requests +database. + + >>> requests.delete_request(2) + >>> requests.count + 4 + >>> show_holds(requests) + 1 RequestType.held_message hold_1 + 3 RequestType.unsubscription hold_3 + 4 RequestType.held_message hold_4 + 5 RequestType.held_message hold_5 + bar: no + foo: yes + >>> print requests.get_request(2) + None + +We get an exception if we ask to delete a request that isn't in the database. + + >>> requests.delete_request(801) + Traceback (most recent call last): + ... + KeyError: 801 + +For the next section, we first clean up all the current requests. + + >>> for request in requests.held_requests: + ... requests.delete_request(request.id) + >>> requests.count + 0 + + +Application support +------------------- + +There are several higher level interfaces available in the mailman.app package +which can be used to hold messages, subscription, and unsubscriptions. There +are also interfaces for disposing of these requests in an application specific +and consistent way. + + >>> from mailman.app import moderator + + +Holding messages +---------------- + +For this section, we need a mailing list and at least one message. + + >>> mlist = config.db.list_manager.create(u'alist@example.com') + >>> mlist.preferred_language = u'en' + >>> mlist.real_name = u'A Test List' + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: alist@example.com + ... Subject: Something important + ... + ... Here's something important about our mailing list. + ... """) + +Holding a message means keeping a copy of it that a moderator must approve +before the message is posted to the mailing list. To hold the message, you +must supply the message, message metadata, and a text reason for the hold. In +this case, we won't include any additional metadata. + + >>> id_1 = moderator.hold_message(mlist, msg, {}, 'Needs approval') + >>> requests.get_request(id_1) is not None + True + +We can also hold a message with some additional metadata. + + # Delete the Message-ID from the previous hold so we don't try to store + # collisions in the message storage. + >>> del msg['message-id'] + >>> msgdata = dict(sender='aperson@example.com', + ... approved=True, + ... received_time=123.45) + >>> id_2 = moderator.hold_message(mlist, msg, msgdata, u'Feeling ornery') + >>> requests.get_request(id_2) is not None + True + +Once held, the moderator can select one of several dispositions. The most +trivial is to simply defer a decision for now. + + >>> from mailman.interfaces import Action + >>> moderator.handle_message(mlist, id_1, Action.defer) + >>> requests.get_request(id_1) is not None + True + +The moderator can also discard the message. This is often done with spam. +Bye bye message! + + >>> moderator.handle_message(mlist, id_1, Action.discard) + >>> print requests.get_request(id_1) + None + >>> virginq.files + [] + +The message can be rejected, meaning it is bounced back to the sender. + + >>> moderator.handle_message(mlist, id_2, Action.reject, 'Off topic') + >>> print requests.get_request(id_2) + None + >>> qmsg, qdata = dequeue() + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Request to mailing list "A Test List" rejected + From: alist-bounces@example.com + To: aperson@example.org + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + Your request to the alist@example.com mailing list + <BLANKLINE> + Posting of your message titled "Something important" + <BLANKLINE> + has been rejected by the list moderator. The moderator gave the + following reason for rejecting your request: + <BLANKLINE> + "Off topic" + <BLANKLINE> + Any questions or comments should be directed to the list administrator + at: + <BLANKLINE> + alist-owner@example.com + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'aperson@example.org'] + reduced_list_headers: True + version : 3 + +Or the message can be approved. This actually places the message back into +the incoming queue for further processing, however the message metadata +indicates that the message has been approved. + + >>> id_3 = moderator.hold_message(mlist, msg, msgdata, 'Needs approval') + >>> moderator.handle_message(mlist, id_3, Action.accept) + >>> inq = config.switchboards['in'] + >>> qmsg, qdata = dequeue(inq) + >>> print qmsg.as_string() + From: aperson@example.org + To: alist@example.com + Subject: Something important + Message-ID: ... + X-Message-ID-Hash: ... + X-Mailman-Approved-At: ... + <BLANKLINE> + Here's something important about our mailing list. + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + approved : True + moderator_approved: True + sender : aperson@example.com + version : 3 + +In addition to any of the above dispositions, the message can also be +preserved for further study. Ordinarily the message is removed from the +global message store after its disposition (though approved messages may be +re-added to the message store). When handling a message, we can tell the +moderator interface to also preserve a copy, essentially telling it not to +delete the message from the storage. First, without the switch, the message +is deleted. + + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: alist@example.com + ... Subject: Something important + ... Message-ID: <12345> + ... + ... Here's something important about our mailing list. + ... """) + >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval') + >>> moderator.handle_message(mlist, id_4, Action.discard) + >>> print config.db.message_store.get_message_by_id(u'<12345>') + None + +But if we ask to preserve the message when we discard it, it will be held in +the message store after disposition. + + >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval') + >>> moderator.handle_message(mlist, id_4, Action.discard, preserve=True) + >>> stored_msg = config.db.message_store.get_message_by_id(u'<12345>') + >>> print stored_msg.as_string() + From: aperson@example.org + To: alist@example.com + Subject: Something important + Message-ID: <12345> + X-Message-ID-Hash: 4CF7EAU3SIXBPXBB5S6PEUMO62MWGQN6 + <BLANKLINE> + Here's something important about our mailing list. + <BLANKLINE> + +Orthogonal to preservation, the message can also be forwarded to another +address. This is helpful for getting the message into the inbox of one of the +moderators. + + # Set a new Message-ID from the previous hold so we don't try to store + # collisions in the message storage. + >>> del msg['message-id'] + >>> msg['Message-ID'] = u'<abcde>' + >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval') + >>> moderator.handle_message(mlist, id_4, Action.discard, + ... forward=[u'zperson@example.com']) + >>> qmsg, qdata = dequeue() + >>> print qmsg.as_string() + Subject: Forward of moderated message + From: alist-bounces@example.com + To: zperson@example.com + MIME-Version: 1.0 + Content-Type: message/rfc822 + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + From: aperson@example.org + To: alist@example.com + Subject: Something important + Message-ID: <abcde> + X-Message-ID-Hash: EN2R5UQFMOUTCL44FLNNPLSXBIZW62ER + <BLANKLINE> + Here's something important about our mailing list. + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'zperson@example.com'] + reduced_list_headers: True + version : 3 + + +Holding subscription requests +----------------------------- + +For closed lists, subscription requests will also be held for moderator +approval. In this case, several pieces of information related to the +subscription must be provided, including the subscriber's address and real +name, their password (possibly hashed), what kind of delivery option they are +chosing and their preferred language. + + >>> from mailman.interfaces.member import DeliveryMode + >>> mlist.admin_immed_notify = False + >>> id_3 = moderator.hold_subscription(mlist, + ... u'bperson@example.org', u'Ben Person', + ... u'{NONE}abcxyz', DeliveryMode.regular, u'en') + >>> requests.get_request(id_3) is not None + True + +In the above case the mailing list was not configured to send the list +moderators a notice about the hold, so no email message is in the virgin +queue. + + >>> virginq.files + [] + +But if we set the list up to notify the list moderators immediately when a +message is held for approval, there will be a message placed in the virgin +queue when the message is held. + + >>> mlist.admin_immed_notify = True + >>> # XXX This will almost certainly change once we've worked out the web + >>> # space layout for mailing lists now. + >>> id_4 = moderator.hold_subscription(mlist, + ... u'cperson@example.org', u'Claire Person', + ... u'{NONE}zyxcba', DeliveryMode.regular, u'en') + >>> requests.get_request(id_4) is not None + True + >>> qmsg, qdata = dequeue() + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: New subscription request to list A Test List from + cperson@example.org + From: alist-owner@example.com + To: alist-owner@example.com + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + Your authorization is required for a mailing list subscription request + approval: + <BLANKLINE> + For: cperson@example.org + List: alist@example.com + <BLANKLINE> + At your convenience, visit: + <BLANKLINE> + http://lists.example.com/admindb/alist@example.com + <BLANKLINE> + to process the request. + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'alist-owner@example.com'] + reduced_list_headers: True + tomoderators : True + version : 3 + +Once held, the moderator can select one of several dispositions. The most +trivial is to simply defer a decision for now. + + >>> moderator.handle_subscription(mlist, id_3, Action.defer) + >>> requests.get_request(id_3) is not None + True + +The held subscription can also be discarded. + + >>> moderator.handle_subscription(mlist, id_3, Action.discard) + >>> print requests.get_request(id_3) + None + +The request can be rejected, in which case a message is sent to the +subscriber. + + >>> moderator.handle_subscription(mlist, id_4, Action.reject, + ... 'This is a closed list') + >>> print requests.get_request(id_4) + None + >>> qmsg, qdata = dequeue() + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Request to mailing list "A Test List" rejected + From: alist-bounces@example.com + To: cperson@example.org + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + Your request to the alist@example.com mailing list + <BLANKLINE> + Subscription request + <BLANKLINE> + has been rejected by the list moderator. The moderator gave the + following reason for rejecting your request: + <BLANKLINE> + "This is a closed list" + <BLANKLINE> + Any questions or comments should be directed to the list administrator + at: + <BLANKLINE> + alist-owner@example.com + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'cperson@example.org'] + reduced_list_headers: True + version : 3 + +The subscription can also be accepted. This subscribes the address to the +mailing list. + + >>> mlist.send_welcome_msg = True + >>> id_4 = moderator.hold_subscription(mlist, + ... u'fperson@example.org', u'Frank Person', + ... u'{NONE}abcxyz', DeliveryMode.regular, u'en') + +A message will be sent to the moderators telling them about the held +subscription and the fact that they may need to approve it. + + >>> qmsg, qdata = dequeue() + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: New subscription request to list A Test List from + fperson@example.org + From: alist-owner@example.com + To: alist-owner@example.com + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + Your authorization is required for a mailing list subscription request + approval: + <BLANKLINE> + For: fperson@example.org + List: alist@example.com + <BLANKLINE> + At your convenience, visit: + <BLANKLINE> + http://lists.example.com/admindb/alist@example.com + <BLANKLINE> + to process the request. + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'alist-owner@example.com'] + reduced_list_headers: True + tomoderators : True + version : 3 + +Accept the subscription request. + + >>> mlist.admin_notify_mchanges = True + >>> moderator.handle_subscription(mlist, id_4, Action.accept) + +There are now two messages in the virgin queue. One is a welcome message +being sent to the user and the other is a subscription notification that is +sent to the moderators. The only good way to tell which is which is to look +at the recipient list. + + >>> qmsg_1, qdata_1 = dequeue(expected_count=2) + >>> qmsg_2, qdata_2 = dequeue() + >>> if 'fperson@example.org' in qdata_1['recips']: + ... # The first message is the welcome message + ... welcome_qmsg = qmsg_1 + ... welcome_qdata = qdata_1 + ... admin_qmsg = qmsg_2 + ... admin_qdata = qdata_2 + ... else: + ... welcome_qmsg = qmsg_2 + ... welcome_qdata = qdata_2 + ... admin_qmsg = qmsg_1 + ... admin_qdata = qdata_1 + +The welcome message is sent to the person who just subscribed. + + >>> print welcome_qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Welcome to the "A Test List" mailing list + From: alist-request@example.com + To: fperson@example.org + X-No-Archive: yes + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + Welcome to the "A Test List" mailing list! + <BLANKLINE> + To post to this list, send your email to: + <BLANKLINE> + alist@example.com + <BLANKLINE> + General information about the mailing list is at: + <BLANKLINE> + http://lists.example.com/listinfo/alist@example.com + <BLANKLINE> + If you ever want to unsubscribe or change your options (eg, switch to + or from digest mode, change your password, etc.), visit your + subscription page at: + <BLANKLINE> + http://example.com/fperson@example.org + <BLANKLINE> + You can also make such adjustments via email by sending a message to: + <BLANKLINE> + alist-request@example.com + <BLANKLINE> + with the word 'help' in the subject or body (don't include the + quotes), and you will get back a message with instructions. You will + need your password to change your options, but for security purposes, + this email is not included here. There is also a button on your + options page that will send your current password to you. + <BLANKLINE> + >>> dump_msgdata(welcome_qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'fperson@example.org'] + reduced_list_headers: True + verp : False + version : 3 + +The admin message is sent to the moderators. + + >>> print admin_qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: A Test List subscription notification + From: changeme@example.com + To: alist-owner@example.com + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + Frank Person <fperson@example.org> has been successfully subscribed to + A Test List. + <BLANKLINE> + >>> dump_msgdata(admin_qdata) + _parsemsg : False + envsender : changeme@example.com + listname : alist@example.com + nodecorate : True + recips : [] + reduced_list_headers: True + version : 3 + +Frank Person is now a member of the mailing list. + + >>> member = mlist.members.get_member(u'fperson@example.org') + >>> member + <Member: Frank Person <fperson@example.org> + on alist@example.com as MemberRole.member> + >>> member.preferred_language + u'en' + >>> print member.delivery_mode + DeliveryMode.regular + >>> user = config.db.user_manager.get_user(member.address.address) + >>> user.real_name + u'Frank Person' + >>> user.password + u'{NONE}abcxyz' + + +Holding unsubscription requests +------------------------------- + +Some lists, though it is rare, require moderator approval for unsubscriptions. +In this case, only the unsubscribing address is required. Like subscriptions, +unsubscription holds can send the list's moderators an immediate notification. + + >>> mlist.admin_immed_notify = False + >>> from mailman.interfaces.member import MemberRole + >>> user_1 = config.db.user_manager.create_user(u'gperson@example.com') + >>> address_1 = list(user_1.addresses)[0] + >>> address_1.subscribe(mlist, MemberRole.member) + <Member: gperson@example.com on alist@example.com as MemberRole.member> + >>> user_2 = config.db.user_manager.create_user(u'hperson@example.com') + >>> address_2 = list(user_2.addresses)[0] + >>> address_2.subscribe(mlist, MemberRole.member) + <Member: hperson@example.com on alist@example.com as MemberRole.member> + >>> id_5 = moderator.hold_unsubscription(mlist, u'gperson@example.com') + >>> requests.get_request(id_5) is not None + True + >>> virginq.files + [] + >>> mlist.admin_immed_notify = True + >>> id_6 = moderator.hold_unsubscription(mlist, u'hperson@example.com') + >>> qmsg, qdata = dequeue() + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: New unsubscription request from A Test List by hperson@example.com + From: alist-owner@example.com + To: alist-owner@example.com + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + Your authorization is required for a mailing list unsubscription + request approval: + <BLANKLINE> + By: hperson@example.com + From: alist@example.com + <BLANKLINE> + At your convenience, visit: + <BLANKLINE> + http://lists.example.com/admindb/alist@example.com + <BLANKLINE> + to process the request. + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'alist-owner@example.com'] + reduced_list_headers: True + tomoderators : True + version : 3 + +There are now two addresses with held unsubscription requests. As above, one +of the actions we can take is to defer to the decision. + + >>> moderator.handle_unsubscription(mlist, id_5, Action.defer) + >>> requests.get_request(id_5) is not None + True + +The held unsubscription can also be discarded, and the member will remain +subscribed. + + >>> moderator.handle_unsubscription(mlist, id_5, Action.discard) + >>> print requests.get_request(id_5) + None + >>> mlist.members.get_member(u'gperson@example.com') + <Member: gperson@example.com on alist@example.com as MemberRole.member> + +The request can be rejected, in which case a message is sent to the member, +and the person remains a member of the mailing list. + + >>> moderator.handle_unsubscription(mlist, id_6, Action.reject, + ... 'This list is a prison.') + >>> print requests.get_request(id_6) + None + >>> qmsg, qdata = dequeue() + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Request to mailing list "A Test List" rejected + From: alist-bounces@example.com + To: hperson@example.com + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + Your request to the alist@example.com mailing list + <BLANKLINE> + Unsubscription request + <BLANKLINE> + has been rejected by the list moderator. The moderator gave the + following reason for rejecting your request: + <BLANKLINE> + "This list is a prison." + <BLANKLINE> + Any questions or comments should be directed to the list administrator + at: + <BLANKLINE> + alist-owner@example.com + <BLANKLINE> + >>> dump_msgdata(qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'hperson@example.com'] + reduced_list_headers: True + version : 3 + + >>> mlist.members.get_member(u'hperson@example.com') + <Member: hperson@example.com on alist@example.com as MemberRole.member> + +The unsubscription request can also be accepted. This removes the member from +the mailing list. + + >>> mlist.send_goodbye_msg = True + >>> mlist.goodbye_msg = u'So long!' + >>> mlist.admin_immed_notify = False + >>> id_7 = moderator.hold_unsubscription(mlist, u'gperson@example.com') + >>> moderator.handle_unsubscription(mlist, id_7, Action.accept) + >>> print mlist.members.get_member(u'gperson@example.com') + None + +There are now two messages in the virgin queue, one to the member who was just +unsubscribed and another to the moderators informing them of this membership +change. + + >>> qmsg_1, qdata_1 = dequeue(expected_count=2) + >>> qmsg_2, qdata_2 = dequeue() + >>> if 'gperson@example.com' in qdata_1['recips']: + ... # The first message is the goodbye message + ... goodbye_qmsg = qmsg_1 + ... goodbye_qdata = qdata_1 + ... admin_qmsg = qmsg_2 + ... admin_qdata = qdata_2 + ... else: + ... goodbye_qmsg = qmsg_2 + ... goodbye_qdata = qdata_2 + ... admin_qmsg = qmsg_1 + ... admin_qdata = qdata_1 + +The goodbye message... + + >>> print goodbye_qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: You have been unsubscribed from the A Test List mailing list + From: alist-bounces@example.com + To: gperson@example.com + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + So long! + <BLANKLINE> + >>> dump_msgdata(goodbye_qdata) + _parsemsg : False + listname : alist@example.com + nodecorate : True + recips : [u'gperson@example.com'] + reduced_list_headers: True + verp : False + version : 3 + +...and the admin message. + + >>> print admin_qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: A Test List unsubscription notification + From: changeme@example.com + To: alist-owner@example.com + Message-ID: ... + Date: ... + Precedence: bulk + <BLANKLINE> + gperson@example.com has been removed from A Test List. + <BLANKLINE> + >>> dump_msgdata(admin_qdata) + _parsemsg : False + envsender : changeme@example.com + listname : alist@example.com + nodecorate : True + recips : [] + reduced_list_headers: True + version : 3 diff --git a/src/mailman/docs/styles.txt b/src/mailman/docs/styles.txt new file mode 100644 index 000000000..d12e57b08 --- /dev/null +++ b/src/mailman/docs/styles.txt @@ -0,0 +1,156 @@ +List styles +=========== + +List styles are a way to name and apply a canned collection of attribute +settings. Every style has a name, which must be unique within the context of +a specific style manager. There is usually only one global style manager. + +Styles also have a priority, which allows you to specify the order in which +multiple styles will be applied. A style has a `match` function which is used +to determine whether the style should be applied to a particular mailing list +or not. And finally, application of a style to a mailing list can really +modify the mailing list any way it wants. + +Let's start with a vanilla mailing list and a default style manager. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> from mailman.styles.manager import StyleManager + >>> style_manager = StyleManager() + >>> style_manager.populate() + >>> sorted(style.name for style in style_manager.styles) + ['default'] + + +The default style +----------------- + +There is a default style which implements the legacy application of list +defaults from previous versions of Mailman. This style only matching a +mailing list when no other styles match, and it has the lowest priority. The +low priority means that it is matched last and if it matches, it is applied +last. + + >>> default_style = style_manager.get('default') + >>> default_style.name + 'default' + >>> default_style.priority + 0 + >>> sorted(style.name for style in style_manager.styles) + ['default'] + +Given a mailing list, you can ask the style manager to find all the styles +that match the list. The registered styles will be sorted by decreasing +priority and each style's `match()` method will be called in turn. The sorted +list of matching styles will be returned -- but not applied -- by the style +manager's `lookup()` method. + + >>> [style.name for style in style_manager.lookup(mlist)] + ['default'] + + +Registering styles +------------------ + +New styles must implement the IStyle interface. + + >>> from zope.interface import implements + >>> from mailman.interfaces.styles import IStyle + >>> class TestStyle(object): + ... implements(IStyle) + ... name = 'test' + ... priority = 10 + ... def apply(self, mailing_list): + ... # Just does something very simple. + ... mailing_list.msg_footer = u'test footer' + ... def match(self, mailing_list, styles): + ... # Applies to any test list + ... if 'test' in mailing_list.fqdn_listname: + ... styles.append(self) + +You can register a new style with the style manager. + + >>> style_manager.register(TestStyle()) + +And now if you lookup matching styles, you should find only the new test +style. This is because the default style only gets applied when no other +styles match the mailing list. + + >>> sorted(style.name for style in style_manager.lookup(mlist)) + ['test'] + >>> for style in style_manager.lookup(mlist): + ... style.apply(mlist) + >>> mlist.msg_footer + u'test footer' + + +Style priority +-------------- + +When multiple styles match a particular mailing list, they are applied in +descending order of priority. In other words, a priority zero style would be +applied last. + + >>> class AnotherTestStyle(TestStyle): + ... name = 'another' + ... priority = 5 + ... # Use the base class's match() method. + ... def apply(self, mailing_list): + ... mailing_list.msg_footer = u'another footer' + + >>> mlist.msg_footer = u'' + >>> mlist.msg_footer + u'' + >>> style_manager.register(AnotherTestStyle()) + >>> for style in style_manager.lookup(mlist): + ... style.apply(mlist) + >>> mlist.msg_footer + u'another footer' + +You can change the priority of a style, and if you reapply the styles, they +will take effect in the new priority order. + + >>> style_1 = style_manager.get('test') + >>> style_1.priority = 5 + >>> style_2 = style_manager.get('another') + >>> style_2.priority = 10 + >>> for style in style_manager.lookup(mlist): + ... style.apply(mlist) + >>> mlist.msg_footer + u'test footer' + + +Unregistering styles +-------------------- + +You can unregister a style, making it unavailable in the future. + + >>> style_manager.unregister(style_2) + >>> sorted(style.name for style in style_manager.lookup(mlist)) + ['test'] + + +Corner cases +------------ + +If you register a style with the same name as an already registered style, you +get an exception. + + >>> style_manager.register(TestStyle()) + Traceback (most recent call last): + ... + DuplicateStyleError: test + +If you try to register an object that isn't a style, you get an exception. + + >>> style_manager.register(object()) + Traceback (most recent call last): + ... + DoesNotImplement: An object does not implement interface + <InterfaceClass mailman.interfaces.styles.IStyle> + +If you try to unregister a style that isn't registered, you get an exception. + + >>> style_manager.unregister(style_2) + Traceback (most recent call last): + ... + KeyError: 'another' diff --git a/src/mailman/docs/usermanager.txt b/src/mailman/docs/usermanager.txt new file mode 100644 index 000000000..f8cbfeef2 --- /dev/null +++ b/src/mailman/docs/usermanager.txt @@ -0,0 +1,124 @@ +The user manager +================ + +The IUserManager is how you create, delete, and manage users. The Mailman +system instantiates an IUserManager for you based on the configuration +variable MANAGERS_INIT_FUNCTION. The instance is accessible on the global +config object. + + >>> from mailman.interfaces.usermanager import IUserManager + >>> from zope.interface.verify import verifyObject + >>> usermgr = config.db.user_manager + >>> verifyObject(IUserManager, usermgr) + True + + +Creating users +-------------- + +There are several ways you can create a user object. The simplest is to +create a 'blank' user by not providing an address or real name at creation +time. This user will have an empty string as their real name, but will not +have a password. + + >>> from mailman.interfaces.user import IUser + >>> user = usermgr.create_user() + >>> verifyObject(IUser, user) + True + >>> sorted(address.address for address in user.addresses) + [] + >>> user.real_name + u'' + >>> print user.password + None + +The user has preferences, but none of them will be specified. + + >>> print user.preferences + <Preferences ...> + +A user can be assigned a real name. + + >>> user.real_name = u'Anne Person' + >>> sorted(user.real_name for user in usermgr.users) + [u'Anne Person'] + +A user can be assigned a password. + + >>> user.password = u'secret' + >>> sorted(user.password for user in usermgr.users) + [u'secret'] + +You can also create a user with an address to start out with. + + >>> user_2 = usermgr.create_user(u'bperson@example.com') + >>> verifyObject(IUser, user_2) + True + >>> sorted(address.address for address in user_2.addresses) + [u'bperson@example.com'] + >>> sorted(user.real_name for user in usermgr.users) + [u'', u'Anne Person'] + +As above, you can assign a real name to such users. + + >>> user_2.real_name = u'Ben Person' + >>> sorted(user.real_name for user in usermgr.users) + [u'Anne Person', u'Ben Person'] + +You can also create a user with just a real name. + + >>> user_3 = usermgr.create_user(real_name=u'Claire Person') + >>> verifyObject(IUser, user_3) + True + >>> sorted(address.address for address in user.addresses) + [] + >>> sorted(user.real_name for user in usermgr.users) + [u'Anne Person', u'Ben Person', u'Claire Person'] + +Finally, you can create a user with both an address and a real name. + + >>> user_4 = usermgr.create_user(u'dperson@example.com', u'Dan Person') + >>> verifyObject(IUser, user_3) + True + >>> sorted(address.address for address in user_4.addresses) + [u'dperson@example.com'] + >>> sorted(address.real_name for address in user_4.addresses) + [u'Dan Person'] + >>> sorted(user.real_name for user in usermgr.users) + [u'Anne Person', u'Ben Person', u'Claire Person', u'Dan Person'] + + +Deleting users +-------------- + +You delete users by going through the user manager. The deleted user is no +longer available through the user manager iterator. + + >>> usermgr.delete_user(user) + >>> sorted(user.real_name for user in usermgr.users) + [u'Ben Person', u'Claire Person', u'Dan Person'] + + +Finding users +------------- + +You can ask the user manager to find the IUser that controls a particular +email address. You'll get back the original user object if it's found. Note +that the .get_user() method takes a string email address, not an IAddress +object. + + >>> address = list(user_4.addresses)[0] + >>> found_user = usermgr.get_user(address.address) + >>> found_user + <User "Dan Person" at ...> + >>> found_user is user_4 + True + +If the address is not in the user database or does not have a user associated +with it, you will get None back. + + >>> print usermgr.get_user(u'zperson@example.com') + None + >>> user_4.unlink(address) + >>> print usermgr.get_user(address.address) + None diff --git a/src/mailman/docs/users.txt b/src/mailman/docs/users.txt new file mode 100644 index 000000000..ff7b9ca84 --- /dev/null +++ b/src/mailman/docs/users.txt @@ -0,0 +1,195 @@ +Users +===== + +Users are entities that represent people. A user has a real name and a +password. Optionally a user may have some preferences and a set of addresses +they control. A user also knows which mailing lists they are subscribed to. + +See usermanager.txt for examples of how to create, delete, and find users. + + >>> usermgr = config.db.user_manager + + +User data +--------- + +Users may have a real name and a password. + + >>> user_1 = usermgr.create_user() + >>> user_1.password = u'my password' + >>> user_1.real_name = u'Zoe Person' + >>> sorted(user.real_name for user in usermgr.users) + [u'Zoe Person'] + >>> sorted(user.password for user in usermgr.users) + [u'my password'] + +The password and real name can be changed at any time. + + >>> user_1.real_name = u'Zoe X. Person' + >>> user_1.password = u'another password' + >>> sorted(user.real_name for user in usermgr.users) + [u'Zoe X. Person'] + >>> sorted(user.password for user in usermgr.users) + [u'another password'] + + +Users addresses +--------------- + +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. + +The easiest way to link a user to an address is to just register the new +address on a user object. + + >>> user_1.register(u'zperson@example.com', u'Zoe Person') + <Address: Zoe Person <zperson@example.com> [not verified] at 0x...> + >>> user_1.register(u'zperson@example.org') + <Address: zperson@example.org [not verified] at 0x...> + >>> sorted(address.address for address in user_1.addresses) + [u'zperson@example.com', u'zperson@example.org'] + >>> sorted(address.real_name for address in user_1.addresses) + [u'', u'Zoe Person'] + +You can also create the address separately and then link it to the user. + + >>> address_1 = usermgr.create_address(u'zperson@example.net') + >>> user_1.link(address_1) + >>> sorted(address.address for address in user_1.addresses) + [u'zperson@example.com', u'zperson@example.net', u'zperson@example.org'] + >>> sorted(address.real_name for address in user_1.addresses) + [u'', u'', u'Zoe Person'] + +But don't try to link an address to more than one user. + + >>> another_user = usermgr.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.address) + True + >>> user_1.controls(u'bperson@example.com') + False + +Given a text email address, the user manager can find the user that controls +that address. + + >>> usermgr.get_user(u'zperson@example.com') is user_1 + True + >>> usermgr.get_user(u'zperson@example.net') is user_1 + True + >>> usermgr.get_user(u'zperson@example.org') is user_1 + True + >>> print usermgr.get_user(u'bperson@example.com') + None + +Addresses can also be unlinked from a user. + + >>> user_1.unlink(address_1) + >>> user_1.controls(u'zperson@example.net') + False + >>> print usermgr.get_user(u'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 + + +Users and preferences +--------------------- + +This is a helper function for the following section. + + >>> def show_prefs(prefs): + ... print 'acknowledge_posts :', prefs.acknowledge_posts + ... print 'preferred_language :', prefs.preferred_language + ... print 'receive_list_copy :', prefs.receive_list_copy + ... print 'receive_own_postings :', prefs.receive_own_postings + ... print 'delivery_mode :', prefs.delivery_mode + +Users have preferences, but these preferences have no default settings. + + >>> from mailman.interfaces.preferences import IPreferences + >>> show_prefs(user_1.preferences) + acknowledge_posts : None + preferred_language : None + receive_list_copy : None + receive_own_postings : None + delivery_mode : None + +Some of these preferences are booleans and they can be set to True or False. + + >>> from mailman.constants import DeliveryMode + >>> prefs = user_1.preferences + >>> prefs.acknowledge_posts = True + >>> prefs.preferred_language = u'it' + >>> prefs.receive_list_copy = False + >>> prefs.receive_own_postings = False + >>> prefs.delivery_mode = DeliveryMode.regular + >>> show_prefs(user_1.preferences) + acknowledge_posts : True + preferred_language : it + receive_list_copy : False + receive_own_postings : False + delivery_mode : DeliveryMode.regular + + +Subscriptions +------------- + +Users know which mailing lists they are subscribed to, regardless of +membership role. + + >>> user_1.link(address_1) + >>> sorted(address.address for address in user_1.addresses) + [u'zperson@example.com', u'zperson@example.net', u'zperson@example.org'] + >>> com = usermgr.get_address(u'zperson@example.com') + >>> org = usermgr.get_address(u'zperson@example.org') + >>> net = usermgr.get_address(u'zperson@example.net') + + >>> from mailman.app.lifecycle import create_list + >>> mlist_1 = create_list(u'xtest_1@example.com') + >>> mlist_2 = create_list(u'xtest_2@example.com') + >>> mlist_3 = create_list(u'xtest_3@example.com') + >>> from mailman.interfaces.member import MemberRole + + >>> com.subscribe(mlist_1, MemberRole.member) + <Member: Zoe Person <zperson@example.com> on xtest_1@example.com as + MemberRole.member> + >>> org.subscribe(mlist_2, MemberRole.member) + <Member: zperson@example.org on xtest_2@example.com as MemberRole.member> + >>> org.subscribe(mlist_2, MemberRole.owner) + <Member: zperson@example.org on xtest_2@example.com as MemberRole.owner> + >>> net.subscribe(mlist_3, MemberRole.moderator) + <Member: zperson@example.net on xtest_3@example.com as + MemberRole.moderator> + + >>> memberships = user_1.memberships + >>> from mailman.interfaces.roster import IRoster + >>> from zope.interface.verify import verifyObject + >>> verifyObject(IRoster, memberships) + True + >>> members = sorted(memberships.members) + >>> len(members) + 4 + >>> def sortkey(member): + ... return member.address.address, member.mailing_list, int(member.role) + >>> for member in sorted(members, key=sortkey): + ... print member.address.address, member.mailing_list, member.role + zperson@example.com xtest_1@example.com MemberRole.member + zperson@example.net xtest_3@example.com MemberRole.moderator + zperson@example.org xtest_2@example.com MemberRole.member + zperson@example.org xtest_2@example.com MemberRole.owner |
