summaryrefslogtreecommitdiff
path: root/Mailman
diff options
context:
space:
mode:
Diffstat (limited to 'Mailman')
-rw-r--r--Mailman/Defaults.py.in11
-rw-r--r--Mailman/Handlers/Acknowledge.py22
-rw-r--r--Mailman/Handlers/AfterDelivery.py4
-rw-r--r--Mailman/Handlers/AvoidDuplicates.py66
-rw-r--r--Mailman/Handlers/CalcRecips.py30
-rw-r--r--Mailman/Handlers/Cleanse.py6
-rw-r--r--Mailman/Handlers/CookHeaders.py46
-rw-r--r--Mailman/MailList.py8
-rw-r--r--Mailman/Utils.py2
-rw-r--r--Mailman/constants.py33
-rw-r--r--Mailman/database/listmanager.py1
-rw-r--r--Mailman/database/model/__init__.py9
-rw-r--r--Mailman/database/model/address.py23
-rw-r--r--Mailman/database/model/language.py2
-rw-r--r--Mailman/database/model/mailinglist.py133
-rw-r--r--Mailman/database/model/member.py91
-rw-r--r--Mailman/database/model/preferences.py (renamed from Mailman/database/model/profile.py)27
-rw-r--r--Mailman/database/model/roster.py185
-rw-r--r--Mailman/database/model/user.py33
-rw-r--r--Mailman/database/model/version.py8
-rw-r--r--Mailman/database/types.py4
-rw-r--r--Mailman/database/usermanager.py72
-rw-r--r--Mailman/docs/ack-headers.txt65
-rw-r--r--Mailman/docs/acknowledge.txt186
-rw-r--r--Mailman/docs/addresses.txt218
-rw-r--r--Mailman/docs/after-delivery.txt43
-rw-r--r--Mailman/docs/avoid-duplicates.txt183
-rw-r--r--Mailman/docs/calc-recips.txt130
-rw-r--r--Mailman/docs/cleanse.txt110
-rw-r--r--Mailman/docs/cook-headers.txt304
-rw-r--r--Mailman/docs/listmanager.txt7
-rw-r--r--Mailman/docs/membership.txt240
-rw-r--r--Mailman/docs/mlist-rosters.txt127
-rw-r--r--Mailman/docs/reply-to.txt148
-rw-r--r--Mailman/docs/subject-munging.txt262
-rw-r--r--Mailman/docs/usermanager.txt166
-rw-r--r--Mailman/docs/users.txt218
-rw-r--r--Mailman/interfaces/address.py9
-rw-r--r--Mailman/interfaces/member.py122
-rw-r--r--Mailman/interfaces/mlistrosters.py33
-rw-r--r--Mailman/interfaces/mlistweb.py25
-rw-r--r--Mailman/interfaces/preferences.py69
-rw-r--r--Mailman/interfaces/profile.py53
-rw-r--r--Mailman/interfaces/roster.py25
-rw-r--r--Mailman/interfaces/rosterset.py47
-rw-r--r--Mailman/interfaces/user.py20
-rw-r--r--Mailman/interfaces/usermanager.py56
-rw-r--r--Mailman/testing/test_acknowledge.py (renamed from Mailman/database/model/rosterset.py)33
-rw-r--r--Mailman/testing/test_after_delivery.py32
-rw-r--r--Mailman/testing/test_avoid_duplicates.py33
-rw-r--r--Mailman/testing/test_calc_recips.py32
-rw-r--r--Mailman/testing/test_cleanse.py (renamed from Mailman/testing/test_mlist_rosters.py)8
-rw-r--r--Mailman/testing/test_cook_headers.py35
-rw-r--r--Mailman/testing/test_handlers.py654
-rw-r--r--Mailman/testing/test_membership.py7
-rw-r--r--Mailman/testing/test_user.py4
56 files changed, 2947 insertions, 1573 deletions
diff --git a/Mailman/Defaults.py.in b/Mailman/Defaults.py.in
index d9ded812c..705191b2f 100644
--- a/Mailman/Defaults.py.in
+++ b/Mailman/Defaults.py.in
@@ -916,17 +916,6 @@ $fqdn_realname
${web_page_url}listinfo${cgiext}/${list_name}
"""
-# Where to put subject prefix for 'Re:' messages:
-#
-# old style: Re: [prefix] test
-# new style: [prefix 123] Re: test ... (number is optional)
-#
-# Old style is default for backward compatibility. New style is forced if a
-# list owner set %d (numbering) in prefix. If the site owner had applied new
-# style patch (from SF patch area) before, he/she may want to set this No in
-# mm_cfg.py.
-OLD_STYLE_PREFIXING = Yes
-
# Scrub regular delivery
DEFAULT_SCRUB_NONDIGEST = False
diff --git a/Mailman/Handlers/Acknowledge.py b/Mailman/Handlers/Acknowledge.py
index d02920cde..078c3ac92 100644
--- a/Mailman/Handlers/Acknowledge.py
+++ b/Mailman/Handlers/Acknowledge.py
@@ -36,30 +36,30 @@ __i18n_templates__ = True
def process(mlist, msg, msgdata):
# Extract the sender's address and find them in the user database
sender = msgdata.get('original_sender', msg.get_sender())
- try:
- ack = mlist.getMemberOption(sender, config.AcknowledgePosts)
- if not ack:
- return
- except Errors.NotAMemberError:
+ member = mlist.members.get_member(sender)
+ if member is None:
+ return
+ ack = member.acknowledge_posts
+ if not ack:
return
# Okay, they want acknowledgement of their post. Give them their original
# subject. BAW: do we want to use the decoded header?
origsubj = msgdata.get('origsubj', msg.get('subject', _('(no subject)')))
# Get the user's preferred language
- lang = msgdata.get('lang', mlist.getMemberLanguage(sender))
+ lang = msgdata.get('lang', member.preferred_language)
# Now get the acknowledgement template
realname = mlist.real_name
text = Utils.maketext(
'postack.txt',
{'subject' : Utils.oneline(origsubj, Utils.GetCharSet(lang)),
'listname' : realname,
- 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=1),
- 'optionsurl' : mlist.GetOptionsURL(sender, absolute=1),
- }, lang=lang, mlist=mlist, raw=1)
+ 'listinfo_url': mlist.script_url('listinfo'),
+ 'optionsurl' : member.options_url,
+ }, lang=lang, mlist=mlist, raw=True)
# Craft the outgoing message, with all headers and attributes
# necessary for general delivery. Then enqueue it to the outgoing
# queue.
- subject = _('$realname post acknowledgement')
- usermsg = Message.UserNotification(sender, mlist.GetBouncesEmail(),
+ subject = _('$realname post acknowledgment')
+ usermsg = Message.UserNotification(sender, mlist.bounces_address,
subject, text, lang)
usermsg.send(mlist)
diff --git a/Mailman/Handlers/AfterDelivery.py b/Mailman/Handlers/AfterDelivery.py
index 5f01c9d49..16caf5773 100644
--- a/Mailman/Handlers/AfterDelivery.py
+++ b/Mailman/Handlers/AfterDelivery.py
@@ -20,10 +20,10 @@
This module must appear after the delivery module in the message pipeline.
"""
-import time
+import datetime
def process(mlist, msg, msgdata):
- mlist.last_post_time = time.time()
+ mlist.last_post_time = datetime.datetime.now()
mlist.post_id += 1
diff --git a/Mailman/Handlers/AvoidDuplicates.py b/Mailman/Handlers/AvoidDuplicates.py
index 1ef05628f..a652906a9 100644
--- a/Mailman/Handlers/AvoidDuplicates.py
+++ b/Mailman/Handlers/AvoidDuplicates.py
@@ -31,61 +31,63 @@ COMMASPACE = ', '
def process(mlist, msg, msgdata):
- recips = msgdata['recips']
+ recips = msgdata.get('recips')
# Short circuit
if not recips:
return
- # Seed this set with addresses we don't care about dup avoiding
- explicit_recips = {}
- listaddrs = [mlist.GetListEmail(), mlist.GetBouncesEmail(),
- mlist.GetOwnerEmail(), mlist.GetRequestEmail()]
- for addr in listaddrs:
- explicit_recips[addr] = True
+ # Seed this set with addresses we don't care about dup avoiding.
+ listaddrs = set((mlist.posting_address,
+ mlist.bounces_address,
+ mlist.owner_address,
+ mlist.request_address))
+ explicit_recips = listaddrs.copy()
# Figure out the set of explicit recipients
- ccaddrs = {}
+ cc_addresses = {}
for header in ('to', 'cc', 'resent-to', 'resent-cc'):
addrs = getaddresses(msg.get_all(header, []))
+ header_addresses = dict((addr, formataddr((name, addr)))
+ for name, addr in addrs
+ if addr)
if header == 'cc':
- for name, addr in addrs:
- ccaddrs[addr] = name, addr
- for name, addr in addrs:
- if not addr:
- continue
- # Ignore the list addresses for purposes of dup avoidance
- explicit_recips[addr] = True
+ # Yes, it's possible that an address is mentioned in multiple CC
+ # headers using different names. In that case, the last real name
+ # will win, but that doesn't seem like such a big deal. Besides,
+ # how else would you chose?
+ cc_addresses.update(header_addresses)
+ # Ignore the list addresses for purposes of dup avoidance.
+ explicit_recips |= set(header_addresses)
# Now strip out the list addresses
- for addr in listaddrs:
- del explicit_recips[addr]
+ explicit_recips -= listaddrs
if not explicit_recips:
# No one was explicitly addressed, so we can't do any dup collapsing
return
- newrecips = []
+ newrecips = set()
for r in recips:
# If this recipient is explicitly addressed...
- if explicit_recips.has_key(r):
+ if r in explicit_recips:
send_duplicate = True
# If the member wants to receive duplicates, or if the recipient
- # is not a member at all, just flag the X-Mailman-Duplicate: yes
+ # is not a member at all, they will get a copy.
# header.
- if mlist.isMember(r) and \
- mlist.getMemberOption(r, config.DontReceiveDuplicates):
+ member = mlist.members.get_member(r)
+ if member and not member.receive_list_copy:
send_duplicate = False
# We'll send a duplicate unless the user doesn't wish it. If
# personalization is enabled, the add-dupe-header flag will add a
# X-Mailman-Duplicate: yes header for this user's message.
if send_duplicate:
- msgdata.setdefault('add-dup-header', {})[r] = True
- newrecips.append(r)
- elif ccaddrs.has_key(r):
- del ccaddrs[r]
+ msgdata.setdefault('add-dup-header', set()).add(r)
+ newrecips.add(r)
+ elif r in cc_addresses:
+ del cc_addresses[r]
else:
# Otherwise, this is the first time they've been in the recips
# list. Add them to the newrecips list and flag them as having
# received this message.
- newrecips.append(r)
- # Set the new list of recipients
- msgdata['recips'] = newrecips
+ newrecips.add(r)
+ # Set the new list of recipients. XXX recips should always be a set.
+ msgdata['recips'] = list(newrecips)
# RFC 2822 specifies zero or one CC header
- del msg['cc']
- if ccaddrs:
- msg['Cc'] = COMMASPACE.join([formataddr(i) for i in ccaddrs.values()])
+ if cc_addresses:
+ del msg['cc']
+ msg['CC'] = COMMASPACE.join(cc_addresses.values())
diff --git a/Mailman/Handlers/CalcRecips.py b/Mailman/Handlers/CalcRecips.py
index 5db860a79..29135999c 100644
--- a/Mailman/Handlers/CalcRecips.py
+++ b/Mailman/Handlers/CalcRecips.py
@@ -26,8 +26,8 @@ SendmailDeliver and BulkDeliver modules.
from Mailman import Errors
from Mailman import Message
from Mailman import Utils
-from Mailman.MemberAdaptor import ENABLED
from Mailman.configuration import config
+from Mailman.constants import DeliveryStatus
from Mailman.i18n import _
@@ -35,16 +35,14 @@ from Mailman.i18n import _
def process(mlist, msg, msgdata):
# Short circuit if we've already calculated the recipients list,
# regardless of whether the list is empty or not.
- if msgdata.has_key('recips'):
+ if 'recips' in msgdata:
return
# Should the original sender should be included in the recipients list?
- include_sender = 1
+ include_sender = True
sender = msg.get_sender()
- try:
- if mlist.getMemberOption(sender, config.DontReceiveOwnPosts):
- include_sender = 0
- except Errors.NotAMemberError:
- pass
+ member = mlist.members.get_member(sender)
+ if member and not member.receive_own_postings:
+ include_sender = False
# Support for urgent messages, which bypasses digests and disabled
# delivery and forces an immediate delivery to all members Right Now. We
# are specifically /not/ allowing the site admins password to work here
@@ -71,18 +69,12 @@ delivery. The original message as received by Mailman is attached.
""")
raise Errors.RejectMessage, Utils.wrap(text)
# Calculate the regular recipients of the message
- recips = [mlist.getMemberCPAddress(m)
- for m in mlist.getRegularMemberKeys()
- if mlist.getDeliveryStatus(m) == ENABLED]
+ recips = set(member.address.address
+ for member in mlist.regular_members.members
+ if member.delivery_status == DeliveryStatus.enabled)
# Remove the sender if they don't want to receive their own posts
- if not include_sender:
- try:
- recips.remove(mlist.getMemberCPAddress(sender))
- except (Errors.NotAMemberError, ValueError):
- # Sender does not want to get copies of their own messages (not
- # metoo), but delivery to their address is disabled (nomail). Or
- # the sender is not a member of the mailing list.
- pass
+ if not include_sender and member.address.address in recips:
+ recips.remove(member.address.address)
# Handle topic classifications
do_topic_filters(mlist, msg, msgdata, recips)
# Bookkeeping
diff --git a/Mailman/Handlers/Cleanse.py b/Mailman/Handlers/Cleanse.py
index cb148c8c0..d84f988e3 100644
--- a/Mailman/Handlers/Cleanse.py
+++ b/Mailman/Handlers/Cleanse.py
@@ -39,15 +39,15 @@ def process(mlist, msg, msgdata):
# We remove other headers from anonymous lists
if mlist.anonymous_list:
log.info('post to %s from %s anonymized',
- mlist.internal_name(), msg.get('from'))
+ mlist.fqdn_listname, msg.get('from'))
del msg['from']
del msg['reply-to']
del msg['sender']
# Hotmail sets this one
del msg['x-originating-email']
i18ndesc = str(uheader(mlist, mlist.description, 'From'))
- msg['From'] = formataddr((i18ndesc, mlist.GetListEmail()))
- msg['Reply-To'] = mlist.GetListEmail()
+ msg['From'] = formataddr((i18ndesc, mlist.posting_address))
+ msg['Reply-To'] = mlist.posting_address
# Some headers can be used to fish for membership
del msg['return-receipt-to']
del msg['disposition-notification-to']
diff --git a/Mailman/Handlers/CookHeaders.py b/Mailman/Handlers/CookHeaders.py
index 7b5579fad..5634fa23e 100644
--- a/Mailman/Handlers/CookHeaders.py
+++ b/Mailman/Handlers/CookHeaders.py
@@ -15,7 +15,7 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.
-"""Cook a message's Subject header."""
+"""Cook a message's headers."""
import re
@@ -27,6 +27,7 @@ from email.Utils import parseaddr, formataddr, getaddresses
from Mailman import Utils
from Mailman import Version
from Mailman.configuration import config
+from Mailman.constants import ReplyToMunging
from Mailman.i18n import _
CONTINUATION = ',\n\t'
@@ -75,7 +76,7 @@ def process(mlist, msg, msgdata):
pass
# Mark message so we know we've been here, but leave any existing
# X-BeenThere's intact.
- msg['X-BeenThere'] = mlist.GetListEmail()
+ msg['X-BeenThere'] = mlist.posting_address
# Add Precedence: and other useful headers. None of these are standard
# and finding information on some of them are fairly difficult. Some are
# just common practice, and we'll add more here as they become necessary.
@@ -89,11 +90,11 @@ def process(mlist, msg, msgdata):
# known exploits in a particular version of Mailman and we know a site is
# using such an old version, they may be vulnerable. It's too easy to
# edit the code to add a configuration variable to handle this.
- if not msg.has_key('x-mailman-version'):
+ if 'x-mailman-version' not in msg:
msg['X-Mailman-Version'] = Version.VERSION
# We set "Precedence: list" because this is the recommendation from the
# sendmail docs, the most authoritative source of this header's semantics.
- if not msg.has_key('precedence'):
+ if 'precedence' not in msg:
msg['Precedence'] = 'list'
# Reply-To: munging. Do not do this if the message is "fast tracked",
# meaning it is internally crafted and delivered to a specific user. BAW:
@@ -109,12 +110,12 @@ def process(mlist, msg, msgdata):
d = {}
def add(pair):
lcaddr = pair[1].lower()
- if d.has_key(lcaddr):
+ if lcaddr in d:
return
d[lcaddr] = pair
new.append(pair)
# List admin wants an explicit Reply-To: added
- if mlist.reply_goes_to_list == 2:
+ if mlist.reply_goes_to_list == ReplyToMunging.explicit_header:
add(parseaddr(mlist.reply_to_address))
# If we're not first stripping existing Reply-To: then we need to add
# the original Reply-To:'s to the list we're building up. In both
@@ -127,9 +128,9 @@ def process(mlist, msg, msgdata):
# Set Reply-To: header to point back to this list. Add this last
# because some folks think that some MUAs make it easier to delete
# addresses from the right than from the left.
- if mlist.reply_goes_to_list == 1:
+ if mlist.reply_goes_to_list == ReplyToMunging.point_to_list:
i18ndesc = uheader(mlist, mlist.description, 'Reply-To')
- add((str(i18ndesc), mlist.GetListEmail()))
+ add((str(i18ndesc), mlist.posting_address))
del msg['reply-to']
# Don't put Reply-To: back if there's nothing to add!
if new:
@@ -146,8 +147,9 @@ def process(mlist, msg, msgdata):
# above code?
# Also skip Cc if this is an anonymous list as list posting address
# is already in From and Reply-To in this case.
- if mlist.personalize == 2 and mlist.reply_goes_to_list <> 1 \
- and not mlist.anonymous_list:
+ if (mlist.personalize == 2 and
+ mlist.reply_goes_to_list <> ReplyToMunging.point_to_list and
+ not mlist.anonymous_list):
# Watch out for existing Cc headers, merge, and remove dups. Note
# that RFC 2822 says only zero or one Cc header is allowed.
new = []
@@ -168,7 +170,7 @@ def process(mlist, msg, msgdata):
if msgdata.get('_nolist') or not mlist.include_rfc2369_headers:
return
# This will act like an email address for purposes of formataddr()
- listid = '%s.%s' % (mlist.internal_name(), mlist.host_name)
+ listid = '%s.%s' % (mlist.list_name, mlist.host_name)
cset = Utils.GetCharSet(mlist.preferred_language)
if mlist.description:
# Don't wrap the header since here we just want to get it properly RFC
@@ -184,9 +186,9 @@ def process(mlist, msg, msgdata):
# For internally crafted messages, we also add a (nonstandard),
# "X-List-Administrivia: yes" header. For all others (i.e. those coming
# from list posts), we add a bunch of other RFC 2369 headers.
- requestaddr = mlist.GetRequestEmail()
- subfieldfmt = '<%s>, <mailto:%s?subject=%ssubscribe>'
- listinfo = mlist.GetScriptURL('listinfo', absolute=1)
+ requestaddr = mlist.request_address
+ subfieldfmt = '<%s>, <mailto:%s>'
+ listinfo = mlist.script_url('listinfo')
headers = {}
# XXX reduced_list_headers used to suppress List-Help, List-Subject, and
# List-Unsubscribe from UserNotification. That doesn't seem to make sense
@@ -194,15 +196,15 @@ def process(mlist, msg, msgdata):
# suppressed).
headers.update({
'List-Help' : '<mailto:%s?subject=help>' % requestaddr,
- 'List-Unsubscribe': subfieldfmt % (listinfo, requestaddr, 'un'),
- 'List-Subscribe' : subfieldfmt % (listinfo, requestaddr, ''),
+ 'List-Unsubscribe': subfieldfmt % (listinfo, mlist.leave_address),
+ 'List-Subscribe' : subfieldfmt % (listinfo, mlist.join_address),
})
if msgdata.get('reduced_list_headers'):
headers['X-List-Administrivia'] = 'yes'
else:
# List-Post: is controlled by a separate attribute
if mlist.include_list_post_header:
- headers['List-Post'] = '<mailto:%s>' % mlist.GetListEmail()
+ headers['List-Post'] = '<mailto:%s>' % mlist.posting_address
# Add this header if we're archiving
if mlist.archive:
archiveurl = mlist.GetBaseArchiveURL()
@@ -226,9 +228,9 @@ def prefix_subject(mlist, msg, msgdata):
# Add the subject prefix unless the message is a digest or is being fast
# tracked (e.g. internally crafted, delivered to a single user such as the
# list admin).
- prefix = mlist.subject_prefix.strip()
- if not prefix:
+ if not mlist.subject_prefix.strip():
return
+ prefix = mlist.subject_prefix
subject = msg.get('subject', '')
# Try to figure out what the continuation_ws is for the header
if isinstance(subject, Header):
@@ -261,9 +263,6 @@ def prefix_subject(mlist, msg, msgdata):
# prefix have number, so we should search prefix w/number in subject.
# Also, force new style.
prefix_pattern = p.sub(r'\s*\d+\s*', prefix_pattern)
- old_style = False
- else:
- old_style = config.OLD_STYLE_PREFIXING
subject = re.sub(prefix_pattern, '', subject)
rematch = re.match('((RE|AW|SV|VS)(\[\d+\])?:\s*)+', subject, re.I)
if rematch:
@@ -284,9 +283,6 @@ def prefix_subject(mlist, msg, msgdata):
# Get the header as a Header instance, with proper unicode conversion
if not recolon:
h = uheader(mlist, prefix, 'Subject', continuation_ws=ws)
- elif old_style:
- h = uheader(mlist, recolon, 'Subject', continuation_ws=ws)
- h.append(prefix)
else:
h = uheader(mlist, prefix, 'Subject', continuation_ws=ws)
h.append(recolon)
diff --git a/Mailman/MailList.py b/Mailman/MailList.py
index 0e57aa0a8..9dff467ce 100644
--- a/Mailman/MailList.py
+++ b/Mailman/MailList.py
@@ -185,14 +185,6 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin,
- # IMailingListIdentity
-
- @property
- def fqdn_listname(self):
- return Utils.fqdn_listname(self._data.list_name, self._data.host_name)
-
-
-
# IMailingListAddresses
@property
diff --git a/Mailman/Utils.py b/Mailman/Utils.py
index 27a61567e..064de570e 100644
--- a/Mailman/Utils.py
+++ b/Mailman/Utils.py
@@ -475,7 +475,7 @@ def findtext(templatefile, dict=None, raw=False, lang=None, mlist=None):
# Calculate the locations to scan
searchdirs = []
if mlist is not None:
- searchdirs.append(mlist.fullpath())
+ searchdirs.append(mlist.full_path)
searchdirs.append(os.path.join(config.TEMPLATE_DIR, mlist.host_name))
searchdirs.append(os.path.join(config.TEMPLATE_DIR, 'site'))
searchdirs.append(config.TEMPLATE_DIR)
diff --git a/Mailman/constants.py b/Mailman/constants.py
index 852704364..fcf5e9678 100644
--- a/Mailman/constants.py
+++ b/Mailman/constants.py
@@ -18,6 +18,9 @@
"""Various constants and enumerations."""
from munepy import Enum
+from zope.interface import implements
+
+from Mailman.interfaces import IPreferences
@@ -42,3 +45,33 @@ class DeliveryStatus(Enum):
by_bounces = 3
# Delivery was disabled by an administrator or moderator
by_moderator = 4
+
+
+
+class MemberRole(Enum):
+ member = 1
+ owner = 2
+ moderator = 3
+
+
+
+class SystemDefaultPreferences(object):
+ implements(IPreferences)
+
+ acknowledge_posts = False
+ hide_address = True
+ preferred_language = 'en'
+ receive_list_copy = True
+ receive_own_postings = True
+ delivery_mode = DeliveryMode.regular
+ delivery_status = DeliveryStatus.enabled
+
+
+
+class ReplyToMunging(Enum):
+ # The Reply-To header is passed through untouched
+ no_munging = 0
+ # The mailing list's posting address is appended to the Reply-To header
+ point_to_list = 1
+ # An explicit Reply-To header is added
+ explicit_header = 2
diff --git a/Mailman/database/listmanager.py b/Mailman/database/listmanager.py
index de8abbd58..83c913ffb 100644
--- a/Mailman/database/listmanager.py
+++ b/Mailman/database/listmanager.py
@@ -54,7 +54,6 @@ class ListManager(object):
def delete(self, mlist):
# Delete the wrapped backing data. XXX It's kind of icky to reach
# into the MailList object this way.
- mlist._data.delete_rosters()
mlist._data.delete()
mlist._data = None
diff --git a/Mailman/database/model/__init__.py b/Mailman/database/model/__init__.py
index 11ca11f89..82e66eb0b 100644
--- a/Mailman/database/model/__init__.py
+++ b/Mailman/database/model/__init__.py
@@ -19,9 +19,7 @@ __all__ = [
'Address',
'Language',
'MailingList',
- 'Profile',
- 'Roster',
- 'RosterSet',
+ 'Preferences',
'User',
'Version',
]
@@ -44,9 +42,8 @@ from Mailman.configuration import config
from Mailman.database.model.address import Address
from Mailman.database.model.language import Language
from Mailman.database.model.mailinglist import MailingList
-from Mailman.database.model.profile import Profile
-from Mailman.database.model.roster import Roster
-from Mailman.database.model.rosterset import RosterSet
+from Mailman.database.model.member import Member
+from Mailman.database.model.preferences import Preferences
from Mailman.database.model.user import User
from Mailman.database.model.version import Version
diff --git a/Mailman/database/model/address.py b/Mailman/database/model/address.py
index 53d5016e5..897b41696 100644
--- a/Mailman/database/model/address.py
+++ b/Mailman/database/model/address.py
@@ -21,11 +21,12 @@ from zope.interface import implements
from Mailman.interfaces import IAddress
-
-ROSTER_KIND = 'Mailman.database.model.roster.Roster'
-USER_KIND = 'Mailman.database.model.user.User'
+MEMBER_KIND = 'Mailman.database.model.member.Member'
+PREFERENCE_KIND = 'Mailman.database.model.preferences.Preferences'
+USER_KIND = 'Mailman.database.model.user.User'
+
class Address(Entity):
implements(IAddress)
@@ -35,8 +36,10 @@ class Address(Entity):
has_field('registered_on', DateTime)
has_field('validated_on', DateTime)
# Relationships
- has_and_belongs_to_many('rosters', of_kind=ROSTER_KIND)
- belongs_to('user', of_kind=USER_KIND)
+ belongs_to('user', of_kind=USER_KIND)
+ belongs_to('preferences', of_kind=PREFERENCE_KIND)
+ # Options
+ using_options(shortnames=True)
def __str__(self):
return formataddr((self.real_name, self.address))
@@ -44,3 +47,13 @@ class Address(Entity):
def __repr__(self):
return '<Address: %s [%s]>' % (
str(self), ('verified' if self.verified else 'not verified'))
+
+ def subscribe(self, mlist, role):
+ from Mailman.database.model import Member
+ from Mailman.database.model import Preferences
+ # This member has no preferences by default.
+ member = Member(role=role,
+ mailing_list=mlist.fqdn_listname,
+ address=self)
+ member.preferences = Preferences()
+ return member
diff --git a/Mailman/database/model/language.py b/Mailman/database/model/language.py
index 3597a128d..e065d5bad 100644
--- a/Mailman/database/model/language.py
+++ b/Mailman/database/model/language.py
@@ -20,3 +20,5 @@ from elixir import *
class Language(Entity):
has_field('code', Unicode)
+ # Options
+ using_options(shortnames=True)
diff --git a/Mailman/database/model/mailinglist.py b/Mailman/database/model/mailinglist.py
index 28e2c11dc..4feb64db4 100644
--- a/Mailman/database/model/mailinglist.py
+++ b/Mailman/database/model/mailinglist.py
@@ -21,6 +21,7 @@ from zope.interface import implements
from Mailman.Utils import fqdn_listname, split_listname
from Mailman.configuration import config
from Mailman.interfaces import *
+from Mailman.database.types import EnumType
@@ -30,6 +31,7 @@ class MailingList(Entity):
IMailingListAddresses,
IMailingListIdentity,
IMailingListRosters,
+ IMailingListWeb,
)
# List identity
@@ -49,17 +51,7 @@ class MailingList(Entity):
has_field('digest_last_sent_at', Float),
has_field('one_last_digest', PickleType),
has_field('volume', Integer),
- has_field('last_post_time', Float),
- # OldStyleMemberships attributes, temporarily stored as pickles.
- has_field('bounce_info', PickleType),
- has_field('delivery_status', PickleType),
- has_field('digest_members', PickleType),
- has_field('language', PickleType),
- has_field('members', PickleType),
- has_field('passwords', PickleType),
- has_field('topics_userinterest', PickleType),
- has_field('user_options', PickleType),
- has_field('usernames', PickleType),
+ has_field('last_post_time', DateTime),
# Attributes which are directly modifiable via the web u/i. The more
# complicated attributes are currently stored as pickles, though that
# will change as the schema and implementation is developed.
@@ -145,7 +137,7 @@ class MailingList(Entity):
has_field('private_roster', Boolean),
has_field('real_name', Unicode),
has_field('reject_these_nonmembers', PickleType),
- has_field('reply_goes_to_list', Boolean),
+ has_field('reply_goes_to_list', EnumType),
has_field('reply_to_address', Unicode),
has_field('require_explicit_destination', Boolean),
has_field('respond_to_post_requests', Boolean),
@@ -163,113 +155,40 @@ class MailingList(Entity):
has_field('umbrella_member_suffix', Unicode),
has_field('unsubscribe_policy', Integer),
has_field('welcome_msg', Unicode),
- # Indirect relationships
- has_field('owner_rosterset', Unicode),
- has_field('moderator_rosterset', Unicode),
# Relationships
## has_and_belongs_to_many(
## 'available_languages',
## of_kind='Mailman.database.model.languages.Language')
+ # Options
+ using_options(shortnames=True)
def __init__(self, fqdn_listname):
super(MailingList, self).__init__()
listname, hostname = split_listname(fqdn_listname)
self.list_name = listname
self.host_name = hostname
- # Create two roster sets, one for the owners and one for the
- # moderators. MailingLists are connected to RosterSets indirectly, in
- # order to preserve the ability to store user data and list data in
- # different databases.
- name = fqdn_listname + ' owners'
- self.owner_rosterset = name
- roster = config.user_manager.create_roster(name)
- config.user_manager.create_rosterset(name).add(roster)
- name = fqdn_listname + ' moderators'
- self.moderator_rosterset = name
- roster = config.user_manager.create_roster(name)
- config.user_manager.create_rosterset(name).add(roster)
-
- def delete_rosters(self):
- listname = fqdn_listname(self.list_name, self.host_name)
- # Delete the list owner roster and roster set.
- name = listname + ' owners'
- roster = config.user_manager.get_roster(name)
- assert roster, 'Missing roster: %s' % name
- config.user_manager.delete_roster(roster)
- rosterset = config.user_manager.get_rosterset(name)
- assert rosterset, 'Missing roster set: %s' % name
- config.user_manager.delete_rosterset(rosterset)
- name = listname + ' moderators'
- roster = config.user_manager.get_roster(name)
- assert roster, 'Missing roster: %s' % name
- config.user_manager.delete_roster(roster)
- rosterset = config.user_manager.get_rosterset(name)
- assert rosterset, 'Missing roster set: %s' % name
- config.user_manager.delete_rosterset(rosterset)
-
- # IMailingListRosters
-
- @property
- def owners(self):
- for user in _collect_users(self.owner_rosterset):
- yield user
-
- @property
- def moderators(self):
- for user in _collect_users(self.moderator_rosterset):
- yield user
-
- @property
- def administrators(self):
- for user in _collect_users(self.owner_rosterset,
- self.moderator_rosterset):
- yield user
+ # Create several rosters for filtering out or querying the membership
+ # table.
+ from Mailman.database.model import roster
+ self.owners = roster.OwnerRoster(self)
+ self.moderators = roster.ModeratorRoster(self)
+ self.administrators = roster.AdministratorRoster(self)
+ self.members = roster.MemberRoster(self)
+ self.regular_members = roster.RegularMemberRoster(self)
+ self.digest_members = roster.DigestMemberRoster(self)
@property
- def owner_rosters(self):
- rosterset = config.user_manager.get_rosterset(self.owner_rosterset)
- for roster in rosterset.rosters:
- yield roster
+ def fqdn_listname(self):
+ """See IMailingListIdentity."""
+ return fqdn_listname(self.list_name, self.host_name)
@property
- def moderator_rosters(self):
- rosterset = config.user_manager.get_rosterset(self.moderator_rosterset)
- for roster in rosterset.rosters:
- yield roster
-
- def add_owner_roster(self, roster):
- rosterset = config.user_manager.get_rosterset(self.owner_rosterset)
- rosterset.add(roster)
-
- def delete_owner_roster(self, roster):
- rosterset = config.user_manager.get_rosterset(self.owner_rosterset)
- rosterset.delete(roster)
-
- def add_moderator_roster(self, roster):
- rosterset = config.user_manager.get_rosterset(self.moderator_rosterset)
- rosterset.add(roster)
+ def web_host(self):
+ """See IMailingListWeb."""
+ return config.domains[self.host_name]
- def delete_moderator_roster(self, roster):
- rosterset = config.user_manager.get_rosterset(self.moderator_rosterset)
- rosterset.delete(roster)
-
-
-
-def _collect_users(*rosterset_names):
- users = set()
- for name in rosterset_names:
- # We have to indirectly look up the roster set's name in the user
- # manager. This is how we enforce separation between the list manager
- # and the user manager storages.
- rosterset = config.user_manager.get_rosterset(name)
- assert rosterset is not None, 'No RosterSet named: %s' % name
- for roster in rosterset.rosters:
- # Rosters collect addresses. It's not required that an address is
- # linked to a user, but it must be the case that all addresses on
- # the owner roster are linked to a user. Get the user that's
- # linked to each address and add it to the set.
- for address in roster.addresses:
- user = config.user_manager.get_user(address.address)
- assert user is not None, 'Unlinked address: ' + address.address
- users.add(user)
- return users
+ def script_url(self, target, context=None):
+ """See IMailingListWeb."""
+ # XXX Handle the case for when context is not None; those would be
+ # relative URLs.
+ return self.web_page_url + target + '/' + self.fqdn_listname
diff --git a/Mailman/database/model/member.py b/Mailman/database/model/member.py
new file mode 100644
index 000000000..1dc942323
--- /dev/null
+++ b/Mailman/database/model/member.py
@@ -0,0 +1,91 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+from elixir import *
+from zope.interface import implements
+
+from Mailman.Utils import split_listname
+from Mailman.constants import SystemDefaultPreferences
+from Mailman.database.types import EnumType
+from Mailman.interfaces import IMember, IPreferences
+
+
+ADDRESS_KIND = 'Mailman.database.model.address.Address'
+PREFERENCE_KIND = 'Mailman.database.model.preferences.Preferences'
+
+
+
+class Member(Entity):
+ implements(IMember)
+
+ has_field('role', EnumType)
+ has_field('mailing_list', Unicode)
+ # Relationships
+ belongs_to('address', of_kind=ADDRESS_KIND)
+ belongs_to('preferences', of_kind=PREFERENCE_KIND)
+ # Options
+ using_options(shortnames=True)
+
+ def __repr__(self):
+ return '<Member: %s on %s as %s>' % (
+ self.address, self.mailing_list, self.role)
+
+ def _lookup(self, preference):
+ pref = getattr(self.preferences, preference)
+ if pref is not None:
+ return pref
+ pref = getattr(self.address.preferences, preference)
+ if pref is not None:
+ return pref
+ if self.address.user:
+ pref = getattr(self.address.user.preferences, preference)
+ if pref is not None:
+ return pref
+ return getattr(SystemDefaultPreferences, preference)
+
+ @property
+ def acknowledge_posts(self):
+ return self._lookup('acknowledge_posts')
+
+ @property
+ def preferred_language(self):
+ return self._lookup('preferred_language')
+
+ @property
+ def receive_list_copy(self):
+ return self._lookup('receive_list_copy')
+
+ @property
+ def receive_own_postings(self):
+ return self._lookup('receive_own_postings')
+
+ @property
+ def delivery_mode(self):
+ return self._lookup('delivery_mode')
+
+ @property
+ def delivery_status(self):
+ return self._lookup('delivery_status')
+
+ @property
+ def options_url(self):
+ # XXX Um, this is definitely wrong
+ return 'http://example.com/' + self.address.address
+
+ def unsubscribe(self):
+ self.preferences.delete()
+ self.delete()
diff --git a/Mailman/database/model/profile.py b/Mailman/database/model/preferences.py
index 49e108728..07d4d84e2 100644
--- a/Mailman/database/model/profile.py
+++ b/Mailman/database/model/preferences.py
@@ -19,13 +19,17 @@ from elixir import *
from email.utils import formataddr
from zope.interface import implements
-from Mailman.constants import DeliveryMode
from Mailman.database.types import EnumType
-from Mailman.interfaces import IProfile
+from Mailman.interfaces import IPreferences
+ADDRESS_KIND = 'Mailman.database.model.address.Address'
+MEMBER_KIND = 'Mailman.database.model.member.Member'
+USER_KIND = 'Mailman.database.model.user.User'
-class Profile(Entity):
- implements(IProfile)
+
+
+class Preferences(Entity):
+ implements(IPreferences)
has_field('acknowledge_posts', Boolean)
has_field('hide_address', Boolean)
@@ -33,14 +37,9 @@ class Profile(Entity):
has_field('receive_list_copy', Boolean)
has_field('receive_own_postings', Boolean)
has_field('delivery_mode', EnumType)
- # Relationships
- belongs_to('user', of_kind='Mailman.database.model.user.User')
+ has_field('delivery_status', EnumType)
+ # Options
+ using_options(shortnames=True)
- def __init__(self):
- super(Profile, self).__init__()
- self.acknowledge_posts = False
- self.hide_address = True
- self.preferred_language = 'en'
- self.receive_list_copy = True
- self.receive_own_postings = True
- self.delivery_mode = DeliveryMode.regular
+ def __repr__(self):
+ return '<Preferences object at %#x>' % id(self)
diff --git a/Mailman/database/model/roster.py b/Mailman/database/model/roster.py
index bf8447433..0730d2b4b 100644
--- a/Mailman/database/model/roster.py
+++ b/Mailman/database/model/roster.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2006-2007 by the Free Software Foundation, Inc.
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
@@ -15,37 +15,172 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.
-from elixir import *
+"""An implementation of an IRoster.
+
+These are hard-coded rosters which know how to filter a set of members to find
+the ones that fit a particular role. These are used as the member, owner,
+moderator, and administrator roster filters.
+"""
+
+from sqlalchemy import *
from zope.interface import implements
-from Mailman.Errors import ExistingAddressError
+from Mailman.constants import DeliveryMode, MemberRole
+from Mailman.constants import SystemDefaultPreferences
+from Mailman.database.model import Address, Member
from Mailman.interfaces import IRoster
-ADDRESS_KIND = 'Mailman.database.model.address.Address'
-ROSTERSET_KIND = 'Mailman.database.model.rosterset.RosterSet'
+
+class AbstractRoster(object):
+ """An abstract IRoster class.
+ This class takes the simple approach of implemented the 'users' and
+ 'addresses' properties in terms of the 'members' property. This may not
+ be the most efficient way, but it works.
-class Roster(Entity):
+ This requires that subclasses implement the 'members' property.
+ """
implements(IRoster)
- has_field('name', Unicode)
- # Relationships
- has_and_belongs_to_many('addresses', of_kind=ADDRESS_KIND)
- has_and_belongs_to_many('roster_set', of_kind=ROSTERSET_KIND)
+ role = None
+
+ def __init__(self, mlist):
+ self._mlist = mlist
+
+ @property
+ def members(self):
+ for member in Member.select_by(mailing_list=self._mlist.fqdn_listname,
+ role=self.role):
+ yield member
+
+ @property
+ def users(self):
+ # Members are linked to addresses, which in turn are linked to users.
+ # So while the 'members' attribute does most of the work, we have to
+ # keep a set of unique users. It's possible for the same user to be
+ # subscribed to a mailing list multiple times with different
+ # addresses.
+ users = set(member.address.user for member in self.members)
+ for user in users:
+ yield user
+
+ @property
+ def addresses(self):
+ # Every Member is linked to exactly one address so the 'members'
+ # attribute does most of the work.
+ for member in self.members:
+ yield member.address
+
+ def get_member(self, address):
+ results = Member.select(
+ and_(Member.c.mailing_list == self._mlist.fqdn_listname,
+ Member.c.role == self.role,
+ Address.c.address == address,
+ Member.c.address_id == Address.c.id))
+ if len(results) == 0:
+ return None
+ elif len(results) == 1:
+ return results[0]
+ else:
+ assert len(results) <= 1, (
+ 'Too many matching member results: %s' % results)
+
+
+
+class MemberRoster(AbstractRoster):
+ """Return all the members of a list."""
+
+ name = 'member'
+ role = MemberRole.member
+
+
+
+class OwnerRoster(AbstractRoster):
+ """Return all the owners of a list."""
+
+ name = 'owner'
+ role = MemberRole.owner
+
+
+
+class ModeratorRoster(AbstractRoster):
+ """Return all the owners of a list."""
+
+ name = 'moderator'
+ role = MemberRole.moderator
+
+
+
+class AdministratorRoster(AbstractRoster):
+ """Return all the administrators of a list."""
+
+ name = 'administrator'
+
+ @property
+ def members(self):
+ # Administrators are defined as the union of the owners and the
+ # moderators.
+ members = Member.select(
+ and_(Member.c.mailing_list == self._mlist.fqdn_listname,
+ or_(Member.c.role == MemberRole.owner,
+ Member.c.role == MemberRole.moderator)))
+ for member in members:
+ yield member
+
+ def get_member(self, address):
+ results = Member.select(
+ and_(Member.c.mailing_list == self._mlist.fqdn_listname,
+ or_(Member.c.role == MemberRole.moderator,
+ Member.c.role == MemberRole.owner),
+ Address.c.address == address,
+ Member.c.address_id == Address.c.id))
+ if len(results) == 0:
+ return None
+ elif len(results) == 1:
+ return results[0]
+ else:
+ assert len(results) <= 1, (
+ 'Too many matching member results: %s' % results)
+
+
+
+class RegularMemberRoster(AbstractRoster):
+ """Return all the regular delivery members of a list."""
+
+ name = 'regular_members'
+
+ @property
+ def members(self):
+ # Query for all the Members which have a role of MemberRole.member and
+ # are subscribed to this mailing list. Then return only those members
+ # that have a regular delivery mode.
+ for member in Member.select_by(mailing_list=self._mlist.fqdn_listname,
+ role=MemberRole.member):
+ if member.delivery_mode == DeliveryMode.regular:
+ yield member
+
+
+
+_digest_modes = (
+ DeliveryMode.mime_digests,
+ DeliveryMode.plaintext_digests,
+ DeliveryMode.summary_digests,
+ )
+
+
+
+class DigestMemberRoster(AbstractRoster):
+ """Return all the regular delivery members of a list."""
+
+ name = 'regular_members'
- def create(self, email_address, real_name=None):
- """See IRoster"""
- from Mailman.database.model.address import Address
- addr = Address.get_by(address=email_address)
- if addr:
- raise ExistingAddressError(email_address)
- addr = Address(address=email_address, real_name=real_name)
- # Make sure all the expected links are made, including to the null
- # (i.e. everyone) roster.
- self.addresses.append(addr)
- addr.rosters.append(self)
- null_roster = Roster.get_by(name='')
- null_roster.addresses.append(addr)
- addr.rosters.append(null_roster)
- return addr
+ @property
+ def members(self):
+ # Query for all the Members which have a role of MemberRole.member and
+ # are subscribed to this mailing list. Then return only those members
+ # that have one of the digest delivery modes.
+ for member in Member.select_by(mailing_list=self._mlist.fqdn_listname,
+ role=MemberRole.member):
+ if member.delivery_mode in _digest_modes:
+ yield member
diff --git a/Mailman/database/model/user.py b/Mailman/database/model/user.py
index be634b9df..683ec0f90 100644
--- a/Mailman/database/model/user.py
+++ b/Mailman/database/model/user.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2006-2007 by the Free Software Foundation, Inc.
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
@@ -21,17 +21,27 @@ from zope.interface import implements
from Mailman import Errors
from Mailman.database.model import Address
+from Mailman.database.model import Preferences
from Mailman.interfaces import IUser
+ADDRESS_KIND = 'Mailman.database.model.address.Address'
+PREFERENCE_KIND = 'Mailman.database.model.preferences.Preferences'
+
+
class User(Entity):
implements(IUser)
has_field('real_name', Unicode)
has_field('password', Unicode)
# Relationships
- has_one('profile', of_kind='Mailman.database.model.profile.Profile')
- has_many('addresses', of_kind='Mailman.database.model.address.Address')
+ has_many('addresses', of_kind=ADDRESS_KIND)
+ belongs_to('preferences', of_kind=PREFERENCE_KIND)
+ # Options
+ using_options(shortnames=True)
+
+ def __repr__(self):
+ return '<User "%s" at %#x>' % (self.real_name, id(self))
def link(self, address):
if address.user is not None:
@@ -46,5 +56,20 @@ class User(Entity):
self.addresses.remove(address)
def controls(self, address):
- found = Address.get_by(address=address.address)
+ found = Address.get_by(address=address)
return bool(found and found.user is self)
+
+ def register(self, address, real_name=None):
+ # First, see if the address already exists
+ addrobj = Address.get_by(address=address)
+ if addrobj is None:
+ if real_name is None:
+ real_name = ''
+ addrobj = Address(address=address, real_name=real_name)
+ addrobj.preferences = Preferences()
+ # Link the address to the user if it is not already linked.
+ if addrobj.user is not None:
+ raise Errors.AddressAlreadyLinkedError(addrobj)
+ addrobj.user = self
+ self.addresses.append(addrobj)
+ return addrobj
diff --git a/Mailman/database/model/version.py b/Mailman/database/model/version.py
index e22e8ae11..7b12778ce 100644
--- a/Mailman/database/model/version.py
+++ b/Mailman/database/model/version.py
@@ -19,7 +19,7 @@ from elixir import *
class Version(Entity):
- with_fields(
- component = Field(String),
- version = Field(Integer),
- )
+ has_field('component', Unicode)
+ has_field('version', Integer)
+ # Options
+ using_options(shortnames=True)
diff --git a/Mailman/database/types.py b/Mailman/database/types.py
index 00ad29559..79ea8767d 100644
--- a/Mailman/database/types.py
+++ b/Mailman/database/types.py
@@ -28,11 +28,15 @@ class EnumType(types.TypeDecorator):
impl = types.String
def convert_bind_param(self, value, engine):
+ if value is None:
+ return None
return '%s:%s.%d' % (value.enumclass.__module__,
value.enumclass.__name__,
int(value))
def convert_result_value(self, value, engine):
+ if value is None:
+ return None
path, intvalue = value.rsplit(':', 1)
modulename, classname = intvalue.rsplit('.', 1)
__import__(modulename)
diff --git a/Mailman/database/usermanager.py b/Mailman/database/usermanager.py
index 97a740803..a37dcf60a 100644
--- a/Mailman/database/usermanager.py
+++ b/Mailman/database/usermanager.py
@@ -35,47 +35,14 @@ from Mailman.interfaces import IUserManager
class UserManager(object):
implements(IUserManager)
- def __init__(self):
- # Create the null roster if it does not already exist. It's more
- # likely to exist than not so try to get it before creating it.
- lockfile = os.path.join(config.LOCK_DIR, '<umgrcreatelock>')
- with LockFile(lockfile):
- roster = self.get_roster('')
- if roster is None:
- self.create_roster('')
- objectstore.flush()
-
- def create_roster(self, name):
- roster = Roster.get_by(name=name)
- if roster:
- raise Errors.RosterExistsError(name)
- return Roster(name=name)
-
- def get_roster(self, name):
- return Roster.get_by(name=name)
-
- def delete_roster(self, roster):
- roster.delete()
-
- @property
- def rosters(self):
- for roster in Roster.select():
- yield roster
-
- def create_rosterset(self, name):
- return RosterSet(name=name)
-
- def delete_rosterset(self, rosterset):
- rosterset.delete()
-
- def get_rosterset(self, name):
- return RosterSet.get_by(name=name)
-
- def create_user(self):
+ def create_user(self, address=None, real_name=None):
user = User()
- # Users always have a profile
- user.profile = Profile()
- user.profile.user = user
+ user.real_name = (real_name if real_name is not None else '')
+ if address:
+ addrobj = Address(address=address, real_name=user.real_name)
+ addrobj.preferences = Preferences()
+ user.link(addrobj)
+ user.preferences = Preferences()
return user
def delete_user(self, user):
@@ -89,3 +56,28 @@ class UserManager(object):
def get_user(self, address):
found = Address.get_by(address=address)
return found and found.user
+
+ def create_address(self, address, real_name=None):
+ found = Address.get_by(address=address)
+ if found:
+ raise Errors.ExistingAddressError(address)
+ if real_name is None:
+ real_name = ''
+ address = Address(address=address, real_name=real_name)
+ address.preferences = Preferences()
+ return address
+
+ def delete_address(self, address):
+ # If there's a user controlling this address, it has to first be
+ # unlinked before the address can be deleted.
+ if address.user:
+ address.user.unlink(address)
+ address.delete()
+
+ def get_address(self, address):
+ return Address.get_by(address=address)
+
+ @property
+ def addresses(self):
+ for address in Address.select():
+ yield address
diff --git a/Mailman/docs/ack-headers.txt b/Mailman/docs/ack-headers.txt
new file mode 100644
index 000000000..b6264b465
--- /dev/null
+++ b/Mailman/docs/ack-headers.txt
@@ -0,0 +1,65 @@
+Acknowledgment headers
+======================
+
+Messages that flow through the global pipeline get their headers 'cooked',
+which basically means that their headers go through several mostly unrelated
+transformations. Some headers get added, others get changed. Some of these
+changes depend on mailing list settings and others depend on how the message
+is getting sent through the system. We'll take things one-by-one.
+
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.CookHeaders import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> mlist.subject_prefix = u''
+ >>> flush()
+
+When the message's metadata has a 'noack' key set, an 'X-Ack: no' header is
+added.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, dict(noack=True))
+ >>> print msg.as_string()
+ From: aperson@example.com
+ X-Ack: no
+ X-BeenThere: _xtest@example.com
+ X-Mailman-Version: ...
+ Precedence: list
+ <BLANKLINE>
+ A message of great import.
+ <BLANKLINE>
+
+Any existing X-Ack header in the original message is removed.
+
+ >>> msg = message_from_string("""\
+ ... X-Ack: yes
+ ... From: aperson@example.com
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, dict(noack=True))
+ >>> print msg.as_string()
+ From: aperson@example.com
+ X-Ack: no
+ X-BeenThere: _xtest@example.com
+ X-Mailman-Version: ...
+ Precedence: list
+ <BLANKLINE>
+ A message of great import.
+ <BLANKLINE>
+
+
+Clean up
+--------
+
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/acknowledge.txt b/Mailman/docs/acknowledge.txt
new file mode 100644
index 000000000..6f47fd64d
--- /dev/null
+++ b/Mailman/docs/acknowledge.txt
@@ -0,0 +1,186 @@
+Message acknowledgment
+======================
+
+When a user posts a message to a mailing list, and that user has chosen to
+receive acknowledgments of their postings, Mailman will sent them such an
+acknowledgment.
+
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.Acknowledge import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> mlist.real_name = 'XTest'
+ >>> # XXX This will almost certainly change once we've worked out the web
+ >>> # space layout for mailing lists now.
+ >>> mlist._data.web_page_url = 'http://lists.example.com/'
+ >>> flush()
+
+ >>> # Ensure that the virgin queue is empty, since we'll be checking this
+ >>> # for new auto-response messages.
+ >>> from Mailman.Queue.sbcache import get_switchboard
+ >>> virginq = get_switchboard(config.VIRGINQUEUE_DIR)
+ >>> virginq.files()
+ []
+
+Subscribe a user to the mailing list.
+
+ >>> from Mailman.constants import MemberRole
+ >>> user_1 = config.user_manager.create_user('aperson@example.com')
+ >>> address_1 = list(user_1.addresses)[0]
+ >>> address_1.subscribe(mlist, MemberRole.member)
+ <Member: aperson@example.com on _xtest@example.com as MemberRole.member>
+ >>> flush()
+
+
+Non-member posts
+----------------
+
+Non-members can't get acknowledgments of their posts to the mailing list.
+
+ >>> msg = message_from_string("""\
+ ... From: bperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> virginq.files()
+ []
+
+We can also specify the original sender in the message's metadata. If that
+person is also not a member, no acknowledgment will be sent either.
+
+ >>> msg = message_from_string("""\
+ ... From: bperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, dict(original_sender='cperson@example.com'))
+ >>> virginq.files()
+ []
+
+
+No acknowledgment requested
+---------------------------
+
+Unless the user has requested acknowledgments, they will not get one.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> virginq.files()
+ []
+
+Similarly if the original sender is specified in the message metadata, and
+that sender is a member but not one who has requested acknowledgments, none
+will be sent.
+
+ >>> user_2 = config.user_manager.create_user('dperson@example.com')
+ >>> address_2 = list(user_2.addresses)[0]
+ >>> address_2.subscribe(mlist, MemberRole.member)
+ <Member: dperson@example.com on _xtest@example.com as MemberRole.member>
+ >>> flush()
+
+ >>> process(mlist, msg, dict(original_sender='dperson@example.com'))
+ >>> virginq.files()
+ []
+
+
+Requested acknowledgments
+-------------------------
+
+If the member requests acknowledgments, Mailman will send them one when they
+post to the mailing list.
+
+ >>> user_1.preferences.acknowledge_posts = True
+ >>> flush()
+
+The receipt will include the original message's subject in the response body,
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Subject: Something witty and insightful
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+
+ >>> len(virginq.files())
+ 1
+ >>> qmsg, qdata = virginq.dequeue(virginq.files()[0])
+ >>> virginq.files()
+ []
+ >>> # Print only some of the meta data. The rest is uninteresting.
+ >>> qdata['listname']
+ '_xtest@example.com'
+ >>> qdata['recips']
+ ['aperson@example.com']
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: XTest post acknowledgment
+ From: _xtest-bounces@example.com
+ To: aperson@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Your message entitled
+ <BLANKLINE>
+ Something witty and insightful
+ <BLANKLINE>
+ was successfully received by the XTest mailing list.
+ <BLANKLINE>
+ List info page: http://lists.example.com/listinfo/_xtest@example.com
+ Your preferences: http://example.com/aperson@example.com
+ <BLANKLINE>
+
+If there is no subject, then the receipt will use a generic message.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+
+ >>> len(virginq.files())
+ 1
+ >>> qmsg, qdata = virginq.dequeue(virginq.files()[0])
+ >>> virginq.files()
+ []
+ >>> # Print only some of the meta data. The rest is uninteresting.
+ >>> qdata['listname']
+ '_xtest@example.com'
+ >>> qdata['recips']
+ ['aperson@example.com']
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: XTest post acknowledgment
+ From: _xtest-bounces@example.com
+ To: aperson@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Your message entitled
+ <BLANKLINE>
+ (no subject)
+ <BLANKLINE>
+ was successfully received by the XTest mailing list.
+ <BLANKLINE>
+ List info page: http://lists.example.com/listinfo/_xtest@example.com
+ Your preferences: http://example.com/aperson@example.com
+ <BLANKLINE>
+
+
+Clean up
+--------
+
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/addresses.txt b/Mailman/docs/addresses.txt
index a8cf9f655..c6a2a9873 100644
--- a/Mailman/docs/addresses.txt
+++ b/Mailman/docs/addresses.txt
@@ -1,144 +1,194 @@
-Email addresses and rosters
-===========================
+Email addresses
+===============
-Addresses represent email address, and nothing more. Some addresses are tied
-to users that Mailman knows about. For example, a list member is a user that
-the system knows about, but a non-member posting from a brand new email
-address is a counter-example.
-
-
-Creating a roster
------------------
-
-Email address objects are tied to rosters, and rosters are tied to the user
-manager. To get things started, access the global user manager and create a
-new roster.
+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.
>>> from Mailman.database import flush
>>> from Mailman.configuration import config
>>> mgr = config.user_manager
- >>> roster_1 = mgr.create_roster('roster-1')
- >>> sorted(roster_1.addresses)
- []
Creating addresses
------------------
-Creating a simple email address object is straight forward.
+Addresses are created directly through the user manager, which starts out with
+no addresses.
- >>> addr_1 = roster_1.create('aperson@example.com')
+ >>> sorted(address.address for address in mgr.addresses)
+ []
+
+Creating an unlinked email address is straightforward.
+
+ >>> address_1 = mgr.create_address('aperson@example.com')
>>> flush()
- >>> addr_1.address
- 'aperson@example.com'
- >>> addr_1.real_name is None
- True
+ >>> sorted(address.address for address in mgr.addresses)
+ ['aperson@example.com']
-You can also create an email address object with a real name.
+However, such addresses have no real name.
- >>> addr_2 = roster_1.create('bperson@example.com', 'Barney Person')
- >>> addr_2.address
- 'bperson@example.com'
- >>> addr_2.real_name
- 'Barney Person'
+ >>> address_1.real_name
+ ''
-You can also iterate through all the addresses on a roster.
+You can also create an email address object with a real name.
- >>> sorted(addr.address for addr in roster_1.addresses)
+ >>> address_2 = mgr.create_address('bperson@example.com', 'Ben Person')
+ >>> flush()
+ >>> sorted(address.address for address in mgr.addresses)
['aperson@example.com', 'bperson@example.com']
+ >>> sorted(address.real_name for address in mgr.addresses)
+ ['', 'Ben Person']
-You can create another roster and add a bunch of existing addresses to the
-second roster.
+You can assign real names to existing addresses.
- >>> roster_2 = mgr.create_roster('roster-2')
+ >>> address_1.real_name = 'Anne Person'
>>> flush()
- >>> sorted(roster_2.addresses)
- []
- >>> for address in roster_1.addresses:
- ... roster_2.addresses.append(address)
- >>> roster_2.create('cperson@example.com', 'Charlie Person')
- <Address: Charlie Person <cperson@example.com> [not verified]>
- >>> sorted(addr.address for addr in roster_2.addresses)
+ >>> sorted(address.real_name for address in mgr.addresses)
+ ['Anne Person', 'Ben Person']
+
+These addresses are not linked to users, and can be seen by searching the user
+manager for an associated user.
+
+ >>> print mgr.get_user('aperson@example.com')
+ None
+ >>> print mgr.get_user('bperson@example.com')
+ None
+
+You can create email addresses that are linked to users by using a different
+interface.
+
+ >>> user_1 = mgr.create_user('cperson@example.com', 'Claire Person')
+ >>> flush()
+ >>> sorted(address.address for address in mgr.addresses)
['aperson@example.com', 'bperson@example.com', 'cperson@example.com']
+ >>> sorted(address.real_name for address in mgr.addresses)
+ ['Anne Person', 'Ben Person', 'Claire Person']
-The first roster hasn't been affected.
+And now you can find the associated user.
- >>> sorted(addr.address for addr in roster_1.addresses)
- ['aperson@example.com', 'bperson@example.com']
+ >>> print mgr.get_user('aperson@example.com')
+ None
+ >>> print mgr.get_user('bperson@example.com')
+ None
+ >>> mgr.get_user('cperson@example.com')
+ <User "Claire Person" at ...>
-Removing addresses
+Deleting addresses
------------------
-You can remove an address from a roster just by deleting it.
+You can remove an unlinked address from the usre manager.
- >>> for addr in roster_1.addresses:
- ... if addr.address == 'aperson@example.com':
- ... break
- >>> addr.address
- 'aperson@example.com'
- >>> roster_1.addresses.remove(addr)
- >>> sorted(addr.address for addr in roster_1.addresses)
- ['bperson@example.com']
+ >>> mgr.delete_address(address_1)
+ >>> flush()
+ >>> sorted(address.address for address in mgr.addresses)
+ ['bperson@example.com', 'cperson@example.com']
+ >>> sorted(address.real_name for address in mgr.addresses)
+ ['Ben Person', 'Claire Person']
-Again, this doesn't affect the other rosters.
+Deleting a linked address does not delete the user, but it does unlink the
+address from the user.
- >>> sorted(addr.address for addr in roster_2.addresses)
- ['aperson@example.com', 'bperson@example.com', 'cperson@example.com']
+ >>> sorted(address.address for address in user_1.addresses)
+ ['cperson@example.com']
+ >>> user_1.controls('cperson@example.com')
+ True
+ >>> address_3 = list(user_1.addresses)[0]
+ >>> mgr.delete_address(address_3)
+ >>> flush()
+ >>> sorted(address.address for address in user_1.addresses)
+ []
+ >>> user_1.controls('cperson@example.com')
+ False
+ >>> sorted(address.address for address in mgr.addresses)
+ ['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 isset by default.
+the address was validated on. Neither date is set by default.
- >>> addr = roster_1.create('dperson@example.com', 'David Person')
- >>> addr.registered_on is None
- True
- >>> addr.validated_on is None
- True
+ >>> address_4 = mgr.create_address('dperson@example.com', 'Dan Person')
+ >>> flush()
+ >>> print address_4.registered_on
+ None
+ >>> print address_4.validated_on
+ None
The registered date takes a Python datetime object.
>>> from datetime import datetime
- >>> addr.registered_on = datetime(2007, 5, 8, 22, 54, 1)
- >>> print addr.registered_on
+ >>> address_4.registered_on = datetime(2007, 5, 8, 22, 54, 1)
+ >>> flush()
+ >>> print address_4.registered_on
2007-05-08 22:54:01
- >>> addr.validated_on is None
- True
+ >>> print address_4.validated_on
+ None
And of course, you can also set the validation date.
- >>> addr.validated_on = datetime(2007, 5, 13, 22, 54, 1)
- >>> print addr.registered_on
+ >>> address_4.validated_on = datetime(2007, 5, 13, 22, 54, 1)
+ >>> flush()
+ >>> print address_4.registered_on
2007-05-08 22:54:01
- >>> print addr.validated_on
+ >>> print address_4.validated_on
2007-05-13 22:54:01
-The null roster
----------------
+Subscriptions
+-------------
-All address objects that have been created are members of the null roster.
+Addresses get subscribed to mailing lists, not users. When the address is
+subscribed, a role is specified.
- >>> all = mgr.get_roster('')
- >>> sorted(addr.address for addr in all.addresses)
- ['aperson@example.com', 'bperson@example.com',
- 'cperson@example.com', 'dperson@example.com']
+ >>> address_5 = mgr.create_address('eperson@example.com', 'Elly Person')
+ >>> mlist = config.list_manager.create('_xtext@example.com')
+ >>> from Mailman.constants 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>
+ >>> flush()
-And conversely, all addresses should have the null roster on their list of
-rosters.
+Now that Elly is both an owner and a member of the mailing list.
- >>> for addr in all.addresses:
- ... assert all in addr.rosters, 'Address is missing null roster'
+ >>> 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)
+ []
Clean up
--------
- >>> for roster in mgr.rosters:
- ... mgr.delete_roster(roster)
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> for user in mgr.users:
+ ... mgr.delete_user(user)
+ >>> for address in mgr.addresses:
+ ... mgr.delete_address(address)
>>> flush()
- >>> sorted(roster.name for roster in mgr.rosters)
+ >>> sorted(config.list_manager.names)
+ []
+ >>> sorted(mgr.users)
+ []
+ >>> sorted(mgr.addresses)
[]
diff --git a/Mailman/docs/after-delivery.txt b/Mailman/docs/after-delivery.txt
new file mode 100644
index 000000000..ac2472745
--- /dev/null
+++ b/Mailman/docs/after-delivery.txt
@@ -0,0 +1,43 @@
+After delivery
+==============
+
+After a message is delivered, or more correctly, after it has been processed
+by the rest of the handlers in the incoming queue pipeline, a couple of
+bookkeeping pieces of information are updated.
+
+ >>> import datetime
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.AfterDelivery import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> post_time = datetime.datetime.now() - datetime.timedelta(minutes=10)
+ >>> mlist.last_post_time = post_time
+ >>> mlist.post_id = 10
+ >>> flush()
+
+Processing a message with this handler updates the last_post_time and post_id
+attributes.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... Something interesting.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> flush()
+ >>> mlist.last_post_time > post_time
+ True
+ >>> mlist.post_id
+ 11
+
+
+Clean up
+--------
+
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/avoid-duplicates.txt b/Mailman/docs/avoid-duplicates.txt
new file mode 100644
index 000000000..6a58382fd
--- /dev/null
+++ b/Mailman/docs/avoid-duplicates.txt
@@ -0,0 +1,183 @@
+Avoid duplicates
+================
+
+The AvoidDuplicates handler module implements several strategies to try to
+reduce the reception of duplicate messages. It does this by removing certain
+recipients from the list of recipients that earlier handler modules
+(e.g. CalcRecips) calculates.
+
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.AvoidDuplicates import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> flush()
+
+Create some members we're going to use.
+
+ >>> from Mailman.constants import MemberRole
+ >>> address_a = config.user_manager.create_address('aperson@example.com')
+ >>> address_b = config.user_manager.create_address('bperson@example.com')
+ >>> member_a = address_a.subscribe(mlist, MemberRole.member)
+ >>> member_b = address_b.subscribe(mlist, MemberRole.member)
+ >>> flush()
+ >>> # This is the message metadata dictionary as it would be produced by
+ >>> # the CalcRecips handler.
+ >>> recips = dict(recips=['aperson@example.com', 'bperson@example.com'])
+
+
+Short circuiting
+----------------
+
+The module short-circuits if there are no recipients.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Subject: A message of great import
+ ...
+ ... Something
+ ... """, Message)
+ >>> msgdata = {}
+ >>> process(mlist, msg, msgdata)
+ >>> msgdata
+ {}
+ >>> print msg.as_string()
+ From: aperson@example.com
+ Subject: A message of great import
+ <BLANKLINE>
+ Something
+ <BLANKLINE>
+
+
+Suppressing the list copy
+-------------------------
+
+Members can elect not to receive a list copy of any message on which they are
+explicitly named as a recipient. This is done by setting their
+receive_list_copy preference to False. However, if they aren't mentioned in
+one of the recipient headers (i.e. To, CC, Resent-To, or Resent-CC), then they
+will get a list copy.
+
+ >>> member_a.preferences.receive_list_copy = False
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: Claire Person <cperson@example.com>
+ ...
+ ... Something of great import.
+ ... """, Message)
+ >>> msgdata = recips.copy()
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['aperson@example.com', 'bperson@example.com']
+ >>> print msg.as_string()
+ From: Claire Person <cperson@example.com>
+ <BLANKLINE>
+ Something of great import.
+ <BLANKLINE>
+
+If they're mentioned on the CC line, they won't get a list copy.
+
+ >>> msg = message_from_string("""\
+ ... From: Claire Person <cperson@example.com>
+ ... CC: aperson@example.com
+ ...
+ ... Something of great import.
+ ... """, Message)
+ >>> msgdata = recips.copy()
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['bperson@example.com']
+ >>> print msg.as_string()
+ From: Claire Person <cperson@example.com>
+ CC: aperson@example.com
+ <BLANKLINE>
+ Something of great import.
+ <BLANKLINE>
+
+But if they're mentioned on the CC line and have receive_list_copy set to True
+(the default), then they still get a list copy.
+
+ >>> msg = message_from_string("""\
+ ... From: Claire Person <cperson@example.com>
+ ... CC: bperson@example.com
+ ...
+ ... Something of great import.
+ ... """, Message)
+ >>> msgdata = recips.copy()
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['aperson@example.com', 'bperson@example.com']
+ >>> print msg.as_string()
+ From: Claire Person <cperson@example.com>
+ CC: bperson@example.com
+ <BLANKLINE>
+ Something of great import.
+ <BLANKLINE>
+
+Other headers checked for recipients include the To...
+
+ >>> msg = message_from_string("""\
+ ... From: Claire Person <cperson@example.com>
+ ... To: aperson@example.com
+ ...
+ ... Something of great import.
+ ... """, Message)
+ >>> msgdata = recips.copy()
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['bperson@example.com']
+ >>> print msg.as_string()
+ From: Claire Person <cperson@example.com>
+ To: aperson@example.com
+ <BLANKLINE>
+ Something of great import.
+ <BLANKLINE>
+
+...Resent-To...
+
+ >>> msg = message_from_string("""\
+ ... From: Claire Person <cperson@example.com>
+ ... Resent-To: aperson@example.com
+ ...
+ ... Something of great import.
+ ... """, Message)
+ >>> msgdata = recips.copy()
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['bperson@example.com']
+ >>> print msg.as_string()
+ From: Claire Person <cperson@example.com>
+ Resent-To: aperson@example.com
+ <BLANKLINE>
+ Something of great import.
+ <BLANKLINE>
+
+...and Resent-CC headers.
+
+ >>> msg = message_from_string("""\
+ ... From: Claire Person <cperson@example.com>
+ ... Resent-Cc: aperson@example.com
+ ...
+ ... Something of great import.
+ ... """, Message)
+ >>> msgdata = recips.copy()
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['bperson@example.com']
+ >>> print msg.as_string()
+ From: Claire Person <cperson@example.com>
+ Resent-Cc: aperson@example.com
+ <BLANKLINE>
+ Something of great import.
+ <BLANKLINE>
+
+
+Clean up
+--------
+
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/calc-recips.txt b/Mailman/docs/calc-recips.txt
new file mode 100644
index 000000000..9646778d6
--- /dev/null
+++ b/Mailman/docs/calc-recips.txt
@@ -0,0 +1,130 @@
+Calculating recipients
+======================
+
+Every message that makes it through to the list membership gets sent to a set
+of recipient addresses. These addresses are calculated by one of the handler
+modules and depends on a host of factors.
+
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.CalcRecips import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> flush()
+
+
+Recipients are calculate from the list members, so add a bunch of members to
+start out with. First, create a bunch of addresses...
+
+ >>> address_a = config.user_manager.create_address('aperson@example.com')
+ >>> address_b = config.user_manager.create_address('bperson@example.com')
+ >>> address_c = config.user_manager.create_address('cperson@example.com')
+ >>> address_d = config.user_manager.create_address('dperson@example.com')
+ >>> address_e = config.user_manager.create_address('eperson@example.com')
+ >>> address_f = config.user_manager.create_address('fperson@example.com')
+
+...then subscribe these addresses to the mailing list as members...
+
+ >>> from Mailman.constants import MemberRole
+ >>> member_a = address_a.subscribe(mlist, MemberRole.member)
+ >>> member_b = address_b.subscribe(mlist, MemberRole.member)
+ >>> member_c = address_c.subscribe(mlist, MemberRole.member)
+ >>> member_d = address_d.subscribe(mlist, MemberRole.member)
+ >>> member_e = address_e.subscribe(mlist, MemberRole.member)
+ >>> member_f = address_f.subscribe(mlist, MemberRole.member)
+
+...then make some of the members digest members.
+
+ >>> from Mailman.constants import DeliveryMode
+ >>> member_d.preferences.delivery_mode = DeliveryMode.plaintext_digests
+ >>> member_e.preferences.delivery_mode = DeliveryMode.mime_digests
+ >>> member_f.preferences.delivery_mode = DeliveryMode.summary_digests
+ >>> flush()
+
+
+Short-circuiting
+----------------
+
+Sometimes, the list of recipients already exists in the message metadata.
+This can happen for example, when a message was previously delivered to some
+but not all of the recipients.
+
+ >>> msg = message_from_string("""\
+ ... From: Xavier Person <xperson@example.com>
+ ...
+ ... Something of great import.
+ ... """, Message)
+ >>> recips = set(('qperson@example.com', 'zperson@example.com'))
+ >>> msgdata = dict(recips=recips)
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['qperson@example.com', 'zperson@example.com']
+
+
+Regular delivery recipients
+---------------------------
+
+Regular delivery recipients are those people who get messages from the list as
+soon as they are posted. In other words, these folks are not digest members.
+
+ >>> msgdata = {}
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['aperson@example.com', 'bperson@example.com', 'cperson@example.com']
+
+Members can elect not to receive a list copy of their own postings.
+
+ >>> member_c.preferences.receive_own_postings = False
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: Claire Person <cperson@example.com>
+ ...
+ ... Something of great import.
+ ... """, Message)
+ >>> msgdata = {}
+ >>> process(mlist, msg, msgdata)
+ >>> sorted(msgdata['recips'])
+ ['aperson@example.com', 'bperson@example.com']
+
+Members can also elect not to receive a list copy of any message on which they
+are explicitly named as a recipient. However, see the AvoidDuplicates handler
+for details.
+
+
+Digest recipients
+-----------------
+
+XXX Test various digest deliveries.
+
+
+Urgent messages
+---------------
+
+XXX Test various urgent deliveries:
+ * test_urgent_moderator()
+ * test_urgent_admin()
+ * test_urgent_reject()
+
+
+Clean up
+--------
+
+ >>> for member in mlist.members.members:
+ ... member.unsubscribe()
+ >>> flush()
+ >>> list(mlist.members.members)
+ []
+ >>> for user in config.user_manager.users:
+ ... config.user_manager.delete_user(user)
+ >>> for address in config.user_manager.addresses:
+ ... config.user_manager.delete_address(address)
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.user_manager.users)
+ []
+ >>> list(config.user_manager.addresses)
+ []
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/cleanse.txt b/Mailman/docs/cleanse.txt
new file mode 100644
index 000000000..85b636737
--- /dev/null
+++ b/Mailman/docs/cleanse.txt
@@ -0,0 +1,110 @@
+Cleansing headers
+=================
+
+All messages posted to a list get their headers cleansed. Some headers are
+related to additional permissions that can be granted to the message and other
+headers can be used to fish for membership.
+
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.Cleanse import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> flush()
+
+Headers such as Approved, Approve, and Urgent are used to grant special
+pemissions to individual messages. All may contain a password; the first two
+headers are used by list administrators to pre-approve a message normal held
+for approval. The latter header is used to send a regular message to all
+members, regardless of whether they get digests or not. Because all three
+headers contain passwords, they must be removed from any posted message.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Approved: foobar
+ ... Approve: barfoo
+ ... Urgent: notreally
+ ... Subject: A message of great import
+ ...
+ ... Blah blah blah
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg.as_string()
+ From: aperson@example.com
+ Subject: A message of great import
+ <BLANKLINE>
+ Blah blah blah
+ <BLANKLINE>
+
+Other headers can be used by list members to fish the list for membership, so
+we don't let them go through. These are a mix of standard headers and custom
+headers supported by some mail readers. For example, X-PMRC is supported by
+Pegasus mail. I don't remember what program uses X-Confirm-Reading-To though
+(Some Microsoft product perhaps?).
+
+ >>> msg = message_from_string("""\
+ ... From: bperson@example.com
+ ... Reply-To: bperson@example.org
+ ... Sender: asystem@example.net
+ ... Return-Receipt-To: another@example.com
+ ... Disposition-Notification-To: athird@example.com
+ ... X-Confirm-Reading-To: afourth@example.com
+ ... X-PMRQC: afifth@example.com
+ ... Subject: a message to you
+ ...
+ ... How are you doing?
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg.as_string()
+ From: bperson@example.com
+ Reply-To: bperson@example.org
+ Sender: asystem@example.net
+ Subject: a message to you
+ <BLANKLINE>
+ How are you doing?
+ <BLANKLINE>
+
+
+Anonymous lists
+---------------
+
+Anonymous mailing lists also try to cleanse certain identifying headers from
+the original posting, so that it is at least a bit more difficult to determine
+who sent the message. This isn't perfect though, for example, the body of the
+messages are never scrubbed (though that might not be a bad idea). The From
+and Reply-To headers in the posted message are taken from list attributes.
+
+Hotmail apparently sets X-Originating-Email.
+
+ >>> mlist.anonymous_list = True
+ >>> mlist.description = u'A Test Mailing List'
+ >>> mlist.preferred_language = u'en'
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: bperson@example.com
+ ... Reply-To: bperson@example.org
+ ... Sender: asystem@example.net
+ ... X-Originating-Email: cperson@example.com
+ ... Subject: a message to you
+ ...
+ ... How are you doing?
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg.as_string()
+ Subject: a message to you
+ From: A Test Mailing List <_xtest@example.com>
+ Reply-To: _xtest@example.com
+ <BLANKLINE>
+ How are you doing?
+ <BLANKLINE>
+
+
+Clean up
+--------
+
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/cook-headers.txt b/Mailman/docs/cook-headers.txt
new file mode 100644
index 000000000..cb1b07de0
--- /dev/null
+++ b/Mailman/docs/cook-headers.txt
@@ -0,0 +1,304 @@
+Cooking headers
+===============
+
+Messages that flow through the global pipeline get their headers 'cooked',
+which basically means that their headers go through several mostly unrelated
+transformations. Some headers get added, others get changed. Some of these
+changes depend on mailing list settings and others depend on how the message
+is getting sent through the system. We'll take things one-by-one.
+
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.CookHeaders import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> mlist.subject_prefix = u''
+ >>> mlist.include_list_post_header = False
+ >>> mlist.archive = True
+ >>> # XXX This will almost certainly change once we've worked out the web
+ >>> # space layout for mailing lists now.
+ >>> mlist._data.web_page_url = 'http://lists.example.com/'
+ >>> flush()
+
+
+Saving the original sender
+--------------------------
+
+Because the original sender headers may get deleted or changed, CookHeaders
+will place the sender in the message metadata for safe keeping.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> msgdata = {}
+ >>> process(mlist, msg, msgdata)
+ >>> msgdata['original_sender']
+ 'aperson@example.com'
+
+But if there was no original sender, then the empty string will be saved.
+
+ >>> msg = message_from_string("""\
+ ... Subject: No original sender
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> msgdata = {}
+ >>> process(mlist, msg, msgdata)
+ >>> msgdata['original_sender']
+ ''
+
+
+X-BeenThere header
+------------------
+
+The X-BeenThere header is what Mailman uses to recognize messages that have
+already been processed by this mailing list. It's one small measure against
+mail loops.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> msg['x-beenthere']
+ '_xtest@example.com'
+
+Mailman appends X-BeenThere headers, so if there already is one in the
+original message, the posted message will contain two such headers.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... X-BeenThere: another@example.com
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> sorted(msg.get_all('x-beenthere'))
+ ['_xtest@example.com', 'another@example.com']
+
+
+Mailman version header
+----------------------
+
+Mailman will also insert an X-Mailman-Version header...
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> from Mailman.Version import VERSION
+ >>> msg['x-mailman-version'] == VERSION
+ True
+
+...but only if one doesn't already exist.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... X-Mailman-Version: 3000
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> from Mailman.Version import VERSION
+ >>> msg['x-mailman-version']
+ '3000'
+
+
+Precedence header
+-----------------
+
+Mailman will insert a Precedence header, which is a de-facto standard for
+telling automatic reply software (e.g. vacation(1)) not to respond to this
+message.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> from Mailman.Version import VERSION
+ >>> msg['precedence']
+ 'list'
+
+But Mailman will only add that header if the original message doesn't already
+have one of them.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Precedence: junk
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> from Mailman.Version import VERSION
+ >>> msg['precedence']
+ 'junk'
+
+
+RFC 2919 and 2369 headers
+-------------------------
+
+This is a helper function for the following section.
+
+ >>> def list_headers(msg):
+ ... print '---start---'
+ ... # Sort the List-* headers found in the message. We need to do
+ ... # this because CookHeaders puts them in a dictionary which does
+ ... # not have a guaranteed sort order.
+ ... for header in sorted(msg.keys()):
+ ... parts = header.lower().split('-')
+ ... if 'list' not in parts:
+ ... continue
+ ... for value in msg.get_all(header):
+ ... print '%s: %s' % (header, value)
+ ... print '---end---'
+
+These RFCs define headers for mailing list actions. A mailing list should
+generally add these headers, but not for messages that aren't crafted for a
+specific list (e.g. password reminders in Mailman 2.x).
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, dict(_nolist=True))
+ >>> list_headers(msg)
+ ---start---
+ ---end---
+
+Some people don't like these headers because their mail readers aren't good
+about hiding them. A list owner can turn these headers off.
+
+ >>> mlist.include_rfc2369_headers = False
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> list_headers(msg)
+ ---start---
+ ---end---
+
+But normally, a list will include these headers.
+
+ >>> mlist.include_rfc2369_headers = True
+ >>> mlist.include_list_post_header = True
+ >>> mlist.preferred_language = 'en'
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> list_headers(msg)
+ ---start---
+ List-Archive: <http://www.example.com/pipermail/_xtest@example.com>
+ List-Help: <mailto:_xtest-request@example.com?subject=help>
+ List-Id: <_xtest.example.com>
+ List-Post: <mailto:_xtest@example.com>
+ List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-join@example.com>
+ List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-leave@example.com>
+ ---end---
+
+If the mailing list has a description, then it is included in the List-Id
+header.
+
+ >>> mlist.description = 'My test mailing list'
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> list_headers(msg)
+ ---start---
+ List-Archive: <http://www.example.com/pipermail/_xtest@example.com>
+ List-Help: <mailto:_xtest-request@example.com?subject=help>
+ List-Id: My test mailing list <_xtest.example.com>
+ List-Post: <mailto:_xtest@example.com>
+ List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-join@example.com>
+ List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-leave@example.com>
+ ---end---
+
+Administrative messages crafted by Mailman will have a reduced set of headers.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, dict(reduced_list_headers=True))
+ >>> list_headers(msg)
+ ---start---
+ List-Help: <mailto:_xtest-request@example.com?subject=help>
+ List-Id: My test mailing list <_xtest.example.com>
+ List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-join@example.com>
+ List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-leave@example.com>
+ X-List-Administrivia: yes
+ ---end---
+
+With the normal set of List-* headers, it's still possible to suppress the
+List-Post header, which is reasonable for an announce only mailing list.
+
+ >>> mlist.include_list_post_header = False
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> list_headers(msg)
+ ---start---
+ List-Archive: <http://www.example.com/pipermail/_xtest@example.com>
+ List-Help: <mailto:_xtest-request@example.com?subject=help>
+ List-Id: My test mailing list <_xtest.example.com>
+ List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-join@example.com>
+ List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-leave@example.com>
+ ---end---
+
+And if the list isn't being archived, it makes no sense to add the
+List-Archive header either.
+
+ >>> mlist.include_list_post_header = True
+ >>> mlist.archive = False
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> list_headers(msg)
+ ---start---
+ List-Help: <mailto:_xtest-request@example.com?subject=help>
+ List-Id: My test mailing list <_xtest.example.com>
+ List-Post: <mailto:_xtest@example.com>
+ List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-join@example.com>
+ List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>,
+ <mailto:_xtest-leave@example.com>
+ ---end---
+
+
+Clean up
+--------
+
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/listmanager.txt b/Mailman/docs/listmanager.txt
index 9e237f02f..03943a237 100644
--- a/Mailman/docs/listmanager.txt
+++ b/Mailman/docs/listmanager.txt
@@ -54,17 +54,12 @@ you will get an exception.
Deleting a mailing list
-----------------------
-Deleting an existing mailing list also deletes its rosters and roster sets.
-
- >>> sorted(r.name for r in config.user_manager.rosters)
- ['', '_xtest@example.com moderators', '_xtest@example.com owners']
+Use the list manager to delete a mailing list.
>>> mgr.delete(mlist)
>>> flush()
>>> sorted(mgr.names)
[]
- >>> sorted(r.name for r in config.user_manager.rosters)
- ['']
Attempting to access attributes of the deleted mailing list raises an
exception:
diff --git a/Mailman/docs/membership.txt b/Mailman/docs/membership.txt
new file mode 100644
index 000000000..9b6465d4a
--- /dev/null
+++ b/Mailman/docs/membership.txt
@@ -0,0 +1,240 @@
+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...
+
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> flush()
+ >>> mlist
+ <mailing list "_xtest@example.com" (unlocked) 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.
+
+ >>> user_1 = config.user_manager.create_user(
+ ... 'aperson@example.com', 'Anne Person')
+ >>> flush()
+ >>> user_1.real_name
+ 'Anne Person'
+ >>> sorted(address.address for address in user_1.addresses)
+ ['aperson@example.com']
+
+We can add Anne as an owner of the mailing list, by creating a member role for
+her.
+
+ >>> from Mailman.constants import MemberRole
+ >>> address_1 = list(user_1.addresses)[0]
+ >>> address_1.address
+ 'aperson@example.com'
+ >>> address_1.subscribe(mlist, MemberRole.owner)
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.owner>
+ >>> flush()
+ >>> sorted(member.address.address for member in mlist.owners.members)
+ ['aperson@example.com']
+ >>> sorted(user.real_name for user in mlist.owners.users)
+ ['Anne Person']
+ >>> sorted(address.address for address in mlist.owners.addresses)
+ ['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)
+ ['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 = config.user_manager.create_user(
+ ... 'bperson@example.com', 'Ben Person')
+ >>> flush()
+ >>> user_2.real_name
+ 'Ben Person'
+ >>> address_2 = list(user_2.addresses)[0]
+ >>> address_2.address
+ 'bperson@example.com'
+ >>> address_2.subscribe(mlist, MemberRole.moderator)
+ <Member: Ben Person <bperson@example.com>
+ on _xtest@example.com as MemberRole.moderator>
+ >>> flush()
+ >>> sorted(member.address.address for member in mlist.moderators.members)
+ ['bperson@example.com']
+ >>> sorted(user.real_name for user in mlist.moderators.users)
+ ['Ben Person']
+ >>> sorted(address.address for address in mlist.moderators.addresses)
+ ['bperson@example.com']
+
+Now, both Anne and Ben are list administrators.
+
+ >>> sorted(member.address.address
+ ... for member in mlist.administrators.members)
+ ['aperson@example.com', 'bperson@example.com']
+ >>> sorted(user.real_name for user in mlist.administrators.users)
+ ['Anne Person', 'Ben Person']
+ >>> sorted(address.address for address in mlist.administrators.addresses)
+ ['aperson@example.com', '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 = config.user_manager.create_user(
+ ... 'cperson@example.com', 'Claire Person')
+ >>> flush()
+ >>> user_3.real_name
+ 'Claire Person'
+ >>> address_3 = list(user_3.addresses)[0]
+ >>> address_3.address
+ 'cperson@example.com'
+ >>> address_3.subscribe(mlist, MemberRole.member)
+ <Member: Claire Person <cperson@example.com>
+ on _xtest@example.com as MemberRole.member>
+ >>> flush()
+
+Claire will be a regular delivery member but not a digest member.
+
+ >>> sorted(address.address for address in mlist.members.addresses)
+ ['cperson@example.com']
+ >>> sorted(address.address for address in mlist.regular_members.addresses)
+ ['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>]
+ >>> flush()
+ >>> sorted(address.address for address in mlist.members.addresses)
+ ['aperson@example.com', 'bperson@example.com', 'cperson@example.com']
+ >>> sorted(address.address for address in mlist.regular_members.addresses)
+ ['aperson@example.com', 'bperson@example.com', '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('aperson@example.com')
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.owner>
+ >>> mlist.administrators.get_member('aperson@example.com')
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.owner>
+ >>> mlist.members.get_member('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('zperson@example.com')
+ None
+ >>> print mlist.moderators.get_member('aperson@example.com')
+ None
+ >>> print mlist.members.get_member('zperson@example.com')
+ None
+
+
+Clean up
+--------
+
+ >>> for member in mlist.members.members:
+ ... member.unsubscribe()
+ >>> for admin in mlist.administrators.members:
+ ... admin.unsubscribe()
+ >>> flush()
+ >>> list(mlist.members.members)
+ []
+ >>> list(mlist.administrators.members)
+ []
+ >>> for user in config.user_manager.users:
+ ... config.user_manager.delete_user(user)
+ >>> for address in config.user_manager.addresses:
+ ... config.user_manager.delete_address(address)
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.user_manager.users)
+ []
+ >>> list(config.user_manager.addresses)
+ []
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/mlist-rosters.txt b/Mailman/docs/mlist-rosters.txt
deleted file mode 100644
index 096ceb7d2..000000000
--- a/Mailman/docs/mlist-rosters.txt
+++ /dev/null
@@ -1,127 +0,0 @@
-Mailing list rosters
-====================
-
-Mailing lists use rosters to manage and organize users for various purposes.
-In order to allow for separate storage of mailing list data and user data, the
-connection between mailing list objects and rosters is indirect. Mailing
-lists manage roster names, and these roster names are used to find the rosters
-that contain the actual users.
-
-
-Privileged rosters
-------------------
-
-Mailing lists have two types of privileged users, owners and moderators.
-Owners get to change the configuration of mailing lists and moderators get to
-approve or deny held messages and subscription requests.
-
-When a mailing list is created, it automatically contains a roster for the
-list owners and a roster for the list moderators.
-
- >>> from Mailman.database import flush
- >>> from Mailman.configuration import config
- >>> mlist = config.list_manager.create('_xtest@example.com')
- >>> flush()
- >>> sorted(roster.name for roster in mlist.owner_rosters)
- ['_xtest@example.com owners']
- >>> sorted(roster.name for roster in mlist.moderator_rosters)
- ['_xtest@example.com moderators']
-
-These rosters are initially empty.
-
- >>> owner_roster = list(mlist.owner_rosters)[0]
- >>> sorted(address for address in owner_roster.addresses)
- []
- >>> moderator_roster = list(mlist.moderator_rosters)[0]
- >>> sorted(address for address in moderator_roster.addresses)
- []
-
-You can create new rosters and add them to the list of owner or moderator
-rosters.
-
- >>> roster_1 = config.user_manager.create_roster('roster-1')
- >>> roster_2 = config.user_manager.create_roster('roster-2')
- >>> roster_3 = config.user_manager.create_roster('roster-3')
- >>> flush()
-
-Make roster-1 an owner roster, roster-2 a moderator roster, and roster-3 both
-an owner and a moderator roster.
-
- >>> mlist.add_owner_roster(roster_1)
- >>> mlist.add_moderator_roster(roster_2)
- >>> mlist.add_owner_roster(roster_3)
- >>> mlist.add_moderator_roster(roster_3)
- >>> flush()
-
- >>> sorted(roster.name for roster in mlist.owner_rosters)
- ['_xtest@example.com owners', 'roster-1', 'roster-3']
- >>> sorted(roster.name for roster in mlist.moderator_rosters)
- ['_xtest@example.com moderators', 'roster-2', 'roster-3']
-
-
-Privileged users
-----------------
-
-Rosters are the lower level way of managing owners and moderators, but usually
-you just want to know which users have owner and moderator privileges. You
-can get the list of such users by using different attributes.
-
-Because the rosters are all empty to start with, we can create a bunch of
-users that will end up being our owners and moderators.
-
- >>> aperson = config.user_manager.create_user()
- >>> bperson = config.user_manager.create_user()
- >>> cperson = config.user_manager.create_user()
-
-These users need addresses, because rosters manage addresses.
-
- >>> address_1 = roster_1.create('aperson@example.com', 'Anne Person')
- >>> aperson.link(address_1)
- >>> address_2 = roster_2.create('bperson@example.com', 'Ben Person')
- >>> bperson.link(address_2)
- >>> address_3 = roster_1.create('cperson@example.com', 'Claire Person')
- >>> cperson.link(address_3)
- >>> roster_3.addresses.append(address_3)
- >>> flush()
-
-Now that everything is set up, we can iterate through the various collections
-of privileged users. Here are the owners of the list.
-
- >>> from Mailman.interfaces import IUser
- >>> addresses = []
- >>> for user in mlist.owners:
- ... assert IUser.providedBy(user), 'Non-IUser owner found'
- ... for address in user.addresses:
- ... addresses.append(address.address)
- >>> sorted(addresses)
- ['aperson@example.com', 'cperson@example.com']
-
-Here are the moderators of the list.
-
- >>> addresses = []
- >>> for user in mlist.moderators:
- ... assert IUser.providedBy(user), 'Non-IUser moderator found'
- ... for address in user.addresses:
- ... addresses.append(address.address)
- >>> sorted(addresses)
- ['bperson@example.com', 'cperson@example.com']
-
-The administrators of a mailing list are the union of the owners and
-moderators.
-
- >>> addresses = []
- >>> for user in mlist.administrators:
- ... assert IUser.providedBy(user), 'Non-IUser administrator found'
- ... for address in user.addresses:
- ... addresses.append(address.address)
- >>> sorted(addresses)
- ['aperson@example.com', 'bperson@example.com', 'cperson@example.com']
-
-
-Clean up
---------
-
- >>> config.list_manager.delete(mlist)
- >>> flush()
- >>> [name for name in config.list_manager.names]
- []
diff --git a/Mailman/docs/reply-to.txt b/Mailman/docs/reply-to.txt
new file mode 100644
index 000000000..43f2fcc06
--- /dev/null
+++ b/Mailman/docs/reply-to.txt
@@ -0,0 +1,148 @@
+Reply-to munging
+================
+
+Messages that flow through the global pipeline get their headers 'cooked',
+which basically means that their headers go through several mostly unrelated
+transformations. Some headers get added, others get changed. Some of these
+changes depend on mailing list settings and others depend on how the message
+is getting sent through the system. We'll take things one-by-one.
+
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.CookHeaders import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> mlist.subject_prefix = u''
+ >>> flush()
+
+Reply-to munging refers to the behavior where a mailing list can be configured
+to change or augment an existing Reply-To header in a message posted to the
+list. Reply-to munging is fairly controversial, with arguments made either
+for or against munging.
+
+The Mailman developers, and I believe the majority consensus is to do no
+Reply-to munging, under several principles. Primarily, most reply-to munging
+is requested by people who do not have both a Reply and Reply All button on
+their mail reader. If you do not munge Reply-To, then these buttons will work
+properly, but if you munge the header, it is impossible for these buttons to
+work right, because both will reply to the list. This leads to unfortunate
+accidents where a private message is accidentally posted to the entire list.
+
+However, Mailman gives list owners the option to do Reply-To munging anyway,
+mostly as a way to shut up the really vocal minority who seem to insist on
+this mis-feature.
+
+
+Reply to list
+-------------
+
+A list can be configured to add a Reply-To header pointing back to the mailing
+list's posting address. If there's no Reply-To header in the original
+message, the list's posting address simply gets inserted.
+
+ >>> from Mailman.constants import ReplyToMunging
+ >>> mlist.reply_goes_to_list = ReplyToMunging.point_to_list
+ >>> mlist.preferred_language = 'en'
+ >>> mlist.description = ''
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> len(msg.get_all('reply-to'))
+ 1
+ >>> msg['reply-to']
+ '_xtest@example.com'
+
+It's also possible to strip any existing Reply-To header first, before adding
+the list's posting address.
+
+ >>> mlist.first_strip_reply_to = True
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Reply-To: bperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> len(msg.get_all('reply-to'))
+ 1
+ >>> msg['reply-to']
+ '_xtest@example.com'
+
+If you don't first strip the header, then the list's posting address will just
+get appended to whatever the original version was.
+
+ >>> mlist.first_strip_reply_to = False
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Reply-To: bperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> len(msg.get_all('reply-to'))
+ 1
+ >>> msg['reply-to']
+ 'bperson@example.com, _xtest@example.com'
+
+
+Explicit Reply-To
+-----------------
+
+The list can also be configured to have an explicit Reply-To header.
+
+ >>> mlist.reply_goes_to_list = ReplyToMunging.explicit_header
+ >>> mlist.reply_to_address = 'my-list@example.com'
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> len(msg.get_all('reply-to'))
+ 1
+ >>> msg['reply-to']
+ 'my-list@example.com'
+
+And as before, it's possible to either strip any existing Reply-To header...
+
+ >>> mlist.first_strip_reply_to = True
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Reply-To: bperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> len(msg.get_all('reply-to'))
+ 1
+ >>> msg['reply-to']
+ 'my-list@example.com'
+
+...or not.
+
+ >>> mlist.first_strip_reply_to = False
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Reply-To: bperson@example.com
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> len(msg.get_all('reply-to'))
+ 1
+ >>> msg['reply-to']
+ 'my-list@example.com, bperson@example.com'
+
+
+Clean up
+--------
+
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/subject-munging.txt b/Mailman/docs/subject-munging.txt
new file mode 100644
index 000000000..921efc889
--- /dev/null
+++ b/Mailman/docs/subject-munging.txt
@@ -0,0 +1,262 @@
+Subject munging
+===============
+
+Messages that flow through the global pipeline get their headers 'cooked',
+which basically means that their headers go through several mostly unrelated
+transformations. Some headers get added, others get changed. Some of these
+changes depend on mailing list settings and others depend on how the message
+is getting sent through the system. We'll take things one-by-one.
+
+ >>> from email import message_from_string
+ >>> from Mailman.Message import Message
+ >>> from Mailman.Handlers.CookHeaders import process
+ >>> from Mailman.configuration import config
+ >>> from Mailman.database import flush
+ >>> mlist = config.list_manager.create('_xtest@example.com')
+ >>> mlist.subject_prefix = u''
+ >>> flush()
+
+
+Inserting a prefix
+------------------
+
+Another thing CookHeaders does is 'munge' the Subject header by inserting the
+subject prefix for the list at the front. If there's no subject header in the
+original message, Mailman uses a canned default. In order to do subject
+munging, a mailing list must have a preferred language.
+
+ >>> mlist.subject_prefix = u'[XTest] '
+ >>> mlist.preferred_language = u'en'
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> msgdata = {}
+ >>> process(mlist, msg, msgdata)
+
+The original subject header is stored in the message metadata. We must print
+the new Subject header because it gets converted from a string to an
+email.header.Header instance which has an unhelpful repr.
+
+ >>> msgdata['origsubj']
+ ''
+ >>> print msg['subject']
+ [XTest] (no subject)
+
+If the original message had a Subject header, then the prefix is inserted at
+the beginning of the header's value.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Subject: Something important
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> msgdata = {}
+ >>> process(mlist, msg, msgdata)
+ >>> msgdata['origsubj']
+ 'Something important'
+ >>> print msg['subject']
+ [XTest] Something important
+
+Subject headers are not munged for digest messages.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Subject: Something important
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, dict(isdigest=True))
+ >>> msg['subject']
+ 'Something important'
+
+Nor are they munged for 'fast tracked' messages, which are generally defined
+as messages that Mailman crafts internally.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Subject: Something important
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, dict(_fasttrack=True))
+ >>> msg['subject']
+ 'Something important'
+
+If a Subject header already has a prefix, usually following a Re: marker,
+another one will not be added but the prefix will be moved to the front of the
+header text.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Subject: Re: [XTest] Something important
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest] Re: Something important
+
+If the Subjec header has a prefix at the front of the header text, that's
+where it will stay. This is called 'new style' prefixing and is the only
+option available in Mailman 3.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... Subject: [XTest] Re: Something important
+ ...
+ ... A message of great import.
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest] Re: Something important
+
+
+Internationalized headers
+-------------------------
+
+Internationalization adds some interesting twists to the handling of subject
+prefixes. Part of what makes this interesting is the encoding of i18n headers
+using RFC 2047, and lists whose preferred language is in a different character
+set than the encoded header.
+
+ >>> msg = message_from_string("""\
+ ... Subject: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+ >>> unicode(msg['subject'])
+ u'[XTest] \u30e1\u30fc\u30eb\u30de\u30f3'
+
+
+Prefix numbers
+--------------
+
+Subject prefixes support a placeholder for the numeric post id. Every time a
+message is posted to the mailing list, a 'post id' gets incremented. This is
+a purely sequential integer that increases monotonically. By added a '%d'
+placeholder to the subject prefix, this post id can be included in the prefix.
+
+ >>> mlist.subject_prefix = '[XTest %d] '
+ >>> mlist.post_id = 456
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... Subject: Something important
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest 456] Something important
+
+This works even when the message is a reply, except that in this case, the
+numeric post id in the generated subject prefix is updated with the new post
+id.
+
+ >>> msg = message_from_string("""\
+ ... Subject: [XTest 123] Re: Something important
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest 456] Re: Something important
+
+If the Subject header had old style prefixing, the prefix is moved to the
+front of the header text.
+
+ >>> msg = message_from_string("""\
+ ... Subject: Re: [XTest 123] Something important
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest 456] Re: Something important
+
+
+And of course, the proper thing is done when posting id numbers are included
+in the subject prefix, and the subject is encoded non-ascii.
+
+ >>> msg = message_from_string("""\
+ ... Subject: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest 456] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+ >>> unicode(msg['subject'])
+ u'[XTest 456] \u30e1\u30fc\u30eb\u30de\u30f3'
+
+Even more fun is when the i18n Subject header already has a prefix, possibly
+with a different posting number.
+
+ >>> msg = message_from_string("""\
+ ... Subject: [XTest 123] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest 456] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+
+# XXX This requires Python email patch #1681333 to succeed.
+# >>> unicode(msg['subject'])
+# u'[XTest 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3'
+
+As before, old style subject prefixes are re-ordered.
+
+ >>> msg = message_from_string("""\
+ ... Subject: Re: [XTest 123] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest 456] Re:
+ =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+
+# XXX This requires Python email patch #1681333 to succeed.
+# >>> unicode(msg['subject'])
+# u'[XTest 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3'
+
+
+In this test case, we get an extra space between the prefix and the original
+subject. It's because the original is 'crooked'. Note that a Subject
+starting with '\n ' is generated by some version of Eudora Japanese edition.
+
+ >>> mlist.subject_prefix = '[XTest] '
+ >>> flush()
+ >>> msg = message_from_string("""\
+ ... Subject:
+ ... Important message
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+ >>> print msg['subject']
+ [XTest] Important message
+
+And again, with an RFC 2047 encoded header.
+
+ >>> msg = message_from_string("""\
+ ... Subject:
+ ... =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=
+ ...
+ ... """, Message)
+ >>> process(mlist, msg, {})
+
+# XXX This one does not appear to work the same way as
+# test_subject_munging_prefix_crooked() in the old Python-based tests. I need
+# to get Tokio to look at this.
+# >>> print msg['subject']
+# [XTest] =?iso-2022-jp?b?IBskQiVhITwlayVeJXMbKEI=?=
+
+
+Clean up
+--------
+
+ >>> for mlist in config.list_manager.mailing_lists:
+ ... config.list_manager.delete(mlist)
+ >>> flush()
+ >>> list(config.list_manager.mailing_lists)
+ []
diff --git a/Mailman/docs/usermanager.txt b/Mailman/docs/usermanager.txt
index f79bff8c6..1f863b606 100644
--- a/Mailman/docs/usermanager.txt
+++ b/Mailman/docs/usermanager.txt
@@ -1,100 +1,144 @@
-The user manager and rosters
-============================
+The user manager
+================
-The IUserManager is how you create, delete, and roster objects. Rosters
-manage collections of 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.
+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.configuration import config
+ >>> from Mailman.database import flush
>>> from Mailman.interfaces import IUserManager
>>> mgr = config.user_manager
>>> IUserManager.providedBy(mgr)
True
-The default roster
-------------------
+Creating users
+--------------
-The user manager always contains at least one roster, the 'null' roster or
-'all inclusive roster'.
+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.
- >>> sorted(roster.name for roster in mgr.rosters)
- ['']
+ >>> from Mailman.interfaces import IUser
+ >>> user = mgr.create_user()
+ >>> flush()
+ >>> IUser.providedBy(user)
+ True
+ >>> sorted(address.address for address in user.addresses)
+ []
+ >>> user.real_name
+ ''
+ >>> print user.password
+ None
+The user has preferences, but none of them will be specified.
-Adding rosters
---------------
+ >>> print user.preferences
+ <Preferences ...>
-You create a roster to hold users. The only thing a roster needs is a name,
-basically just an identifying string.
+A user can be assigned a real name.
- >>> from Mailman.database import flush
- >>> from Mailman.interfaces import IRoster
- >>> roster = mgr.create_roster('roster-1')
- >>> IRoster.providedBy(roster)
- True
- >>> roster.name
- 'roster-1'
+ >>> user.real_name = 'Anne Person'
>>> flush()
+ >>> sorted(user.real_name for user in mgr.users)
+ ['Anne Person']
-If you try to create a roster with the same name as an existing roster, you
-will get an exception.
+A user can be assigned a password.
- >>> roster_dup = mgr.create_roster('roster-1')
- Traceback (most recent call last):
- ...
- RosterExistsError: roster-1
+ >>> user.password = 'secret'
+ >>> flush()
+ >>> sorted(user.password for user in mgr.users)
+ ['secret']
+You can also create a user with an address to start out with.
-Deleting a roster
------------------
+ >>> user_2 = mgr.create_user('bperson@example.com')
+ >>> flush()
+ >>> IUser.providedBy(user_2)
+ True
+ >>> sorted(address.address for address in user_2.addresses)
+ ['bperson@example.com']
+ >>> sorted(user.real_name for user in mgr.users)
+ ['', 'Anne Person']
-Delete the roster, and you can then create it again.
+As above, you can assign a real name to such users.
- >>> mgr.delete_roster(roster)
- >>> flush()
- >>> roster = mgr.create_roster('roster-1')
+ >>> user_2.real_name = 'Ben Person'
>>> flush()
- >>> roster.name
- 'roster-1'
+ >>> sorted(user.real_name for user in mgr.users)
+ ['Anne Person', 'Ben Person']
+You can also create a user with just a real name.
-Retrieving a roster
--------------------
+ >>> user_3 = mgr.create_user(real_name='Claire Person')
+ >>> flush()
+ >>> IUser.providedBy(user_3)
+ True
+ >>> sorted(address.address for address in user.addresses)
+ []
+ >>> sorted(user.real_name for user in mgr.users)
+ ['Anne Person', 'Ben Person', 'Claire Person']
-When a roster exists, you can ask the user manager for it and you will always
-get the same object back.
+Finally, you can create a user with both an address and a real name.
- >>> roster_2 = mgr.get_roster('roster-1')
- >>> roster_2.name
- 'roster-1'
- >>> roster is roster_2
+ >>> user_4 = mgr.create_user('dperson@example.com', 'Dan Person')
+ >>> flush()
+ >>> IUser.providedBy(user_3)
True
+ >>> sorted(address.address for address in user_4.addresses)
+ ['dperson@example.com']
+ >>> sorted(address.real_name for address in user_4.addresses)
+ ['Dan Person']
+ >>> sorted(user.real_name for user in mgr.users)
+ ['Anne Person', 'Ben Person', 'Claire Person', 'Dan Person']
-Trying to get a roster that does not yet exist returns None.
- >>> print mgr.get_roster('no roster')
- None
+Deleting users
+--------------
+
+You delete users by going through the user manager. The deleted user is no
+longer available through the user manager iterator.
+
+ >>> mgr.delete_user(user)
+ >>> flush()
+ >>> sorted(user.real_name for user in mgr.users)
+ ['Ben Person', 'Claire Person', '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.
-Iterating over all the rosters
-------------------------------
+ >>> address = list(user_4.addresses)[0]
+ >>> found_user = mgr.get_user(address.address)
+ >>> found_user
+ <User "Dan Person" at ...>
+ >>> found_user is user_4
+ True
-Once you've created a bunch of rosters, you can use the user manager to
-iterate over all the rosters.
+If the address is not in the user database or does not have a user associated
+with it, you will get None back.
- >>> roster_2 = mgr.create_roster('roster-2')
- >>> roster_3 = mgr.create_roster('roster-3')
- >>> roster_4 = mgr.create_roster('roster-4')
+ >>> print mgr.get_user('zperson@example.com')
+ None
+ >>> user_4.unlink(address)
>>> flush()
- >>> sorted(roster.name for roster in mgr.rosters)
- ['', 'roster-1', 'roster-2', 'roster-3', 'roster-4']
+ >>> print mgr.get_user(address.address)
+ None
-Cleaning up
------------
+Clean up
+--------
- >>> for roster in mgr.rosters:
- ... mgr.delete_roster(roster)
+ >>> for user in mgr.users:
+ ... mgr.delete_user(user)
>>> flush()
+ >>> list(mgr.users)
+ []
diff --git a/Mailman/docs/users.txt b/Mailman/docs/users.txt
index caad6b216..2b60ed0bc 100644
--- a/Mailman/docs/users.txt
+++ b/Mailman/docs/users.txt
@@ -1,180 +1,164 @@
Users
=====
-Users are entities that combine addresses, preferences, and a password
-scheme. Password schemes can be anything from a traditional
-challenge/response type password string to an OpenID url.
+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.
-
-Create, deleting, and managing users
-------------------------------------
-
-Users are managed by the IUserManager. Users don't have any unique
-identifying information, and no such id is needed to create them.
+See usermanager.txt for examples of how to create, delete, and find users.
>>> from Mailman.database import flush
>>> from Mailman.configuration import config
>>> mgr = config.user_manager
- >>> user = mgr.create_user()
-Users have a real name, a password scheme, a default profile, and a set of
-addresses that they control. All of these data are None or empty for a newly
-created user.
- >>> user.real_name is None
- True
- >>> user.password is None
- True
- >>> user.addresses
- []
+User data
+---------
-You can iterate over all the users in a user manager.
+Users may have a real name and a password.
- >>> another_user = mgr.create_user()
+ >>> user_1 = mgr.create_user()
+ >>> user_1.password = 'my password'
+ >>> user_1.real_name = 'Zoe Person'
>>> flush()
- >>> all_users = list(mgr.users)
- >>> len(list(all_users))
- 2
- >>> user is not another_user
- True
- >>> user in all_users
- True
- >>> another_user in all_users
- True
+ >>> sorted(user.real_name for user in mgr.users)
+ ['Zoe Person']
+ >>> sorted(user.password for user in mgr.users)
+ ['my password']
-You can also delete users from the user manager.
-
- >>> mgr.delete_user(user)
- >>> mgr.delete_user(another_user)
- >>> flush()
- >>> len(list(mgr.users))
- 0
-
-
-Simple user information
------------------------
-
-Users may have a real name and a password scheme.
+The password and real name can be changed at any time.
- >>> user = mgr.create_user()
- >>> user.password = 'my password'
- >>> user.real_name = 'Zoe Person'
+ >>> user_1.real_name = 'Zoe X. Person'
+ >>> user_1.password = 'another password'
>>> flush()
- >>> only_person = list(mgr.users)[0]
- >>> only_person.password
- 'my password'
- >>> only_person.real_name
- 'Zoe Person'
+ >>> sorted(user.real_name for user in mgr.users)
+ ['Zoe X. Person']
+ >>> sorted(user.password for user in mgr.users)
+ ['another password']
-The password and real name can be changed at any time.
- >>> user.real_name = 'Zoe X. Person'
- >>> user.password = 'another password'
- >>> only_person.real_name
- 'Zoe X. Person'
- >>> only_person.password
- '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.
-Users and addresses
--------------------
+The easiest way to link a user to an address is to just register the new
+address on a user object.
-One of the pieces of information that a user links to is a set of email
-addresses, in the form of IAddress objects. A user can control many
-addresses, but addresses may be control by only one user.
+ >>> user_1.register('zperson@example.com', 'Zoe Person')
+ <Address: Zoe Person <zperson@example.com> [not verified]>
+ >>> user_1.register('zperson@example.org')
+ <Address: zperson@example.org [not verified]>
+ >>> flush()
+ >>> sorted(address.address for address in user_1.addresses)
+ ['zperson@example.com', 'zperson@example.org']
+ >>> sorted(address.real_name for address in user_1.addresses)
+ ['', 'Zoe Person']
-Given a user and an address, you can link the two together.
+You can also create the address separately and then link it to the user.
- >>> roster = mgr.get_roster('')
- >>> address = roster.create('aperson@example.com', 'Anne Person')
- >>> user.link(address)
+ >>> address_1 = mgr.create_address('zperson@example.net')
+ >>> user_1.link(address_1)
>>> flush()
- >>> sorted(address.address for address in user.addresses)
- ['aperson@example.com']
+ >>> sorted(address.address for address in user_1.addresses)
+ ['zperson@example.com', 'zperson@example.net', 'zperson@example.org']
+ >>> sorted(address.real_name for address in user_1.addresses)
+ ['', '', 'Zoe Person']
But don't try to link an address to more than one user.
>>> another_user = mgr.create_user()
- >>> another_user.link(address)
+ >>> another_user.link(address_1)
Traceback (most recent call last):
...
- AddressAlreadyLinkedError: Anne Person <aperson@example.com>
+ AddressAlreadyLinkedError: zperson@example.net
You can also ask whether a given user controls a given address.
- >>> user.controls(address)
+ >>> user_1.controls(address_1.address)
True
- >>> not_my_address = roster.create('bperson@example.com', 'Ben Person')
- >>> user.controls(not_my_address)
+ >>> user_1.controls('bperson@example.com')
False
Given a text email address, the user manager can find the user that controls
that address.
- >>> mgr.get_user('aperson@example.com') is user
+ >>> mgr.get_user('zperson@example.com') is user_1
+ True
+ >>> mgr.get_user('zperson@example.net') is user_1
True
- >>> mgr.get_user('bperson@example.com') is None
+ >>> mgr.get_user('zperson@example.org') is user_1
True
+ >>> print mgr.get_user('bperson@example.com')
+ None
Addresses can also be unlinked from a user.
- >>> user.unlink(address)
- >>> user.controls(address)
+ >>> user_1.unlink(address_1)
+ >>> user_1.controls('zperson@example.net')
False
- >>> mgr.get_user('aperson@example.com') is None
- True
+ >>> print mgr.get_user('aperson@example.net')
+ None
But don't try to unlink the address from a user it's not linked to.
- >>> user.unlink(address)
+ >>> user_1.unlink(address_1)
Traceback (most recent call last):
...
- AddressNotLinkedError: Anne Person <aperson@example.com>
- >>> another_user.unlink(address)
+ AddressNotLinkedError: zperson@example.net
+ >>> another_user.unlink(address_1)
Traceback (most recent call last):
...
- AddressNotLinkedError: Anne Person <aperson@example.com>
- >>> mgr.delete_user(another_user)
+ AddressNotLinkedError: zperson@example.net
-Users and profiles
-------------------
+Users and preferences
+---------------------
-Users always have a default profile.
+This is a helper function for the following section.
- >>> from Mailman.interfaces import IProfile
- >>> IProfile.providedBy(user.profile)
- True
+ >>> 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
-A profile is a set of preferences such as whether the user wants to receive an
-acknowledgment of all of their posts to a mailing list...
+Users have preferences, but these preferences have no default settings.
- >>> user.profile.acknowledge_posts
- False
-
-...whether the user wants to hide their email addresses on web pages and in
-postings to the list...
-
- >>> user.profile.hide_address
- True
+ >>> from Mailman.interfaces import IPreferences
+ >>> show_prefs(user_1.preferences)
+ acknowledge_posts : None
+ preferred_language : None
+ receive_list_copy : None
+ receive_own_postings : None
+ delivery_mode : None
-...the language code for the user's preferred language...
+Some of these preferences are booleans and they can be set to True or False.
- >>> user.profile.preferred_language
- 'en'
-
-...whether the user wants to receive the list's copy of a message if they are
-explicitly named in one of the recipient headers...
-
- >>> user.profile.receive_list_copy
- True
-
-...whether the user wants to receive a copy of their own postings...
+ >>> from Mailman.constants import DeliveryMode
+ >>> prefs = user_1.preferences
+ >>> prefs.acknowledge_posts = True
+ >>> prefs.preferred_language = 'it'
+ >>> prefs.receive_list_copy = False
+ >>> prefs.receive_own_postings = False
+ >>> prefs.delivery_mode = DeliveryMode.regular
+ >>> flush()
+ >>> show_prefs(user_1.preferences)
+ acknowledge_posts : True
+ preferred_language : it
+ receive_list_copy : False
+ receive_own_postings : False
+ delivery_mode : DeliveryMode.regular
- >>> user.profile.receive_own_postings
- True
-...and the preferred delivery method.
+Clean up
+--------
- >>> print user.profile.delivery_mode
- DeliveryMode.regular
+ >>> for user in mgr.users:
+ ... mgr.delete_user(user)
+ >>> flush()
+ >>> sorted(mgr.users)
+ []
diff --git a/Mailman/interfaces/address.py b/Mailman/interfaces/address.py
index 5f6a9193d..c367774c3 100644
--- a/Mailman/interfaces/address.py
+++ b/Mailman/interfaces/address.py
@@ -42,3 +42,12 @@ class IAddress(Interface):
"""The date and time at which this email address was validated, or
None if the email address has not yet been validated. The specific
method of validation is not defined here.""")
+
+ def subscribe(mlist, role):
+ """Subscribe the address to the given mailing list with the given role.
+
+ role is a Mailman.constants.MemberRole enum.
+ """
+
+ preferences = Attribute(
+ """This address's preferences.""")
diff --git a/Mailman/interfaces/member.py b/Mailman/interfaces/member.py
new file mode 100644
index 000000000..9921f7dab
--- /dev/null
+++ b/Mailman/interfaces/member.py
@@ -0,0 +1,122 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+
+"""Interface describing the basics of a member."""
+
+from zope.interface import Interface, Attribute
+
+
+
+class IMember(Interface):
+ """A member of a mailing list."""
+
+ mailing_list = Attribute(
+ """The mailing list subscribed to.""")
+
+ address = Attribute(
+ """The email address that's subscribed to the list.""")
+
+ preferences = Attribute(
+ """This member's preferences.""")
+
+ role = Attribute(
+ """The role of this membership.""")
+
+ def unsubscribe():
+ """Unsubscribe (and delete) this member from the mailing list."""
+
+ acknowledge_posts = Attribute(
+ """Send an acknowledgment for every posting?
+
+ Unlike going through the preferences, this attribute return the
+ preference value based on the following lookup order:
+
+ 1. The member
+ 2. The address
+ 3. The user
+ 4. System default
+ """)
+
+ preferred_language = Attribute(
+ """The preferred language for interacting with a mailing list.
+
+ Unlike going through the preferences, this attribute return the
+ preference value based on the following lookup order:
+
+ 1. The member
+ 2. The address
+ 3. The user
+ 4. System default
+ """)
+
+ receive_list_copy = Attribute(
+ """Should an explicit recipient receive a list copy?
+
+ Unlike going through the preferences, this attribute return the
+ preference value based on the following lookup order:
+
+ 1. The member
+ 2. The address
+ 3. The user
+ 4. System default
+ """)
+
+ receive_own_postings = Attribute(
+ """Should the poster get a list copy of their own messages?
+
+ Unlike going through the preferences, this attribute return the
+ preference value based on the following lookup order:
+
+ 1. The member
+ 2. The address
+ 3. The user
+ 4. System default
+ """)
+
+ delivery_mode = Attribute(
+ """The preferred delivery mode.
+
+ Unlike going through the preferences, this attribute return the
+ preference value based on the following lookup order:
+
+ 1. The member
+ 2. The address
+ 3. The user
+ 4. System default
+ """)
+
+ delivery_status = Attribute(
+ """The delivery status.
+
+ Unlike going through the preferences, this attribute return the
+ preference value based on the following lookup order:
+
+ 1. The member
+ 2. The address
+ 3. The user
+ 4. System default
+
+ XXX I'm not sure this is the right place to put this.""")
+
+ options_url = Attribute(
+ """Return the url for the given member's option page.
+
+ XXX This needs a serious re-think in the face of the unified user
+ database, since a member's options aren't tied to any specific mailing
+ list. So in what part of the web-space does the user's options live?
+ """)
diff --git a/Mailman/interfaces/mlistrosters.py b/Mailman/interfaces/mlistrosters.py
index 1b407f472..9cd20e3ef 100644
--- a/Mailman/interfaces/mlistrosters.py
+++ b/Mailman/interfaces/mlistrosters.py
@@ -46,27 +46,6 @@ class IMailingListRosters(Interface):
This includes the IUsers who are both owners and moderators of the
mailing list.""")
- owner_rosters = Attribute(
- """An iterator over the IRosters containing all the owners of this
- mailing list.""")
-
- moderator_rosters = Attribute(
- """An iterator over the IRosters containing all the moderators of this
- mailing list.""")
-
- def add_owner_roster(roster):
- """Add an IRoster to this mailing list's set of owner rosters."""
-
- def delete_owner_roster(roster):
- """Remove an IRoster from this mailing list's set of owner rosters."""
-
- def add_moderator_roster(roster):
- """Add an IRoster to this mailing list's set of moderator rosters."""
-
- def delete_moderator_roster(roster):
- """Remove an IRoster from this mailing list's set of moderator
- rosters."""
-
members = Attribute(
"""An iterator over all the members of the mailing list, regardless of
whether they are to receive regular messages or digests, or whether
@@ -82,15 +61,3 @@ class IMailingListRosters(Interface):
postings to this mailing list, regardless of whether they have their
deliver disabled or not, or of the type of digest they are to
receive.""")
-
- member_rosters = Attribute(
- """An iterator over the IRosters containing all the members of this
- mailing list.""")
-
- def add_member_roster(roster):
- """Add the given IRoster to the list of rosters for the members of this
- mailing list."""
-
- def remove_member_roster(roster):
- """Remove the given IRoster to the list of rosters for the members of
- this mailing list."""
diff --git a/Mailman/interfaces/mlistweb.py b/Mailman/interfaces/mlistweb.py
index 16eb94281..728fd1990 100644
--- a/Mailman/interfaces/mlistweb.py
+++ b/Mailman/interfaces/mlistweb.py
@@ -21,19 +21,26 @@ from zope.interface import Interface, Attribute
-class IMailingListURLs(Interface):
+class IMailingListWeb(Interface):
"""The web addresses associated with a mailing list."""
protocol = Attribute(
- """The web protocol to use to contact the server providing the web
- interface for this mailing list, e.g. 'http' or 'https'.""")
+ """The protocol scheme used to contact this list's server.
+
+ The web server on thi protocol provides the web interface for this
+ mailing list. The protocol scheme should be 'http' or 'https'.""")
web_host = Attribute(
- """The read-only domain name of the host to contact for interacting
- with the web interface of the mailing list.""")
+ """This list's web server's domain.
+
+ The read-only domain name of the host to contact for interacting with
+ the web interface of the mailing list.""")
def script_url(target, context=None):
- """Return the url to the given script target. If 'context' is not
- given, or is None, then an absolute url is returned. If context is
- given, it must be an IMailingListRequest object, and the returned url
- will be relative to that object's 'location' attribute."""
+ """Return the url to the given script target.
+
+ If 'context' is not given, or is None, then an absolute url is
+ returned. If context is given, it must be an IMailingListRequest
+ object, and the returned url will be relative to that object's
+ 'location' attribute.
+ """
diff --git a/Mailman/interfaces/preferences.py b/Mailman/interfaces/preferences.py
new file mode 100644
index 000000000..0809874e2
--- /dev/null
+++ b/Mailman/interfaces/preferences.py
@@ -0,0 +1,69 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Interface for preferences."""
+
+from zope.interface import Interface, Attribute
+
+
+
+class IPreferences(Interface):
+ """Delivery related information."""
+
+ acknowledge_posts = Attribute(
+ """Send an acknowledgment for every posting?
+
+ This preference can be True, False, or None. True means the user is
+ sent a receipt for each message they send to the mailing list. False
+ means that no receipt is sent. None means no preference is
+ specified.""")
+
+ preferred_language = Attribute(
+ """The preferred language for interacting with a mailing list.
+
+ This is either the language code for the preferred language, or None
+ meaning no preferred language is specified.""")
+
+ receive_list_copy = Attribute(
+ """Should an explicit recipient receive a list copy?
+
+ When a list member is explicitly named in a message's recipients
+ (e.g. the To or CC headers), and this preference is True, the
+ recipient will still receive a list copy of the message. When False,
+ this list copy will be suppressed. None means no preference is
+ specified.""")
+
+ receive_own_postings = Attribute(
+ """Should the poster get a list copy of their own messages?
+
+ When this preference is True, a list copy will be sent to the poster
+ of all messages. When False, this list copy will be suppressed. None
+ means no preference is specified.""")
+
+ delivery_mode = Attribute(
+ """The preferred delivery mode.
+
+ This is an enum constant of the type DeliveryMode. It may also be
+ None which means that no preference is specified.""")
+
+ delivery_status = Attribute(
+ """The delivery status.
+
+ This is an enum constant of type DeliveryStatus. It may also be None
+ which means that no preference is specified.
+
+ XXX I'm not sure this is the right place to put this.""")
diff --git a/Mailman/interfaces/profile.py b/Mailman/interfaces/profile.py
deleted file mode 100644
index ed3968f9d..000000000
--- a/Mailman/interfaces/profile.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# Copyright (C) 2007 by the Free Software Foundation, Inc.
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
-# USA.
-
-"""Interface for a profile, which describes delivery related information."""
-
-from zope.interface import Interface, Attribute
-
-
-
-class IProfile(Interface):
- """Delivery related information."""
-
- acknowledge_posts = Attribute(
- """Boolean specifying whether to send an acknowledgment receipt for
- every posting to the mailing list.
- """)
-
- hide_address = Attribute(
- """Boolean specifying whether to hide this email address from fellow
- list members.
- """)
-
- preferred_language = Attribute(
- """Preferred language for interacting with a mailing list.""")
-
- receive_list_copy = Attribute(
- """Boolean specifying whether to receive a list copy if the user is
- explicitly named in one of the recipient headers.
- """)
-
- receive_own_postings = Attribute(
- """Boolean specifying whether to receive a list copy of the user's own
- postings to the mailing list.
- """)
-
- delivery_mode = Attribute(
- """The preferred delivery mode.
-
- This is an enum constant of the type DeliveryMode.""")
diff --git a/Mailman/interfaces/roster.py b/Mailman/interfaces/roster.py
index 7ddbd5101..3b323d0b4 100644
--- a/Mailman/interfaces/roster.py
+++ b/Mailman/interfaces/roster.py
@@ -22,21 +22,32 @@ from zope.interface import Interface, Attribute
class IRoster(Interface):
- """A roster is a collection of IUsers."""
+ """A roster is a collection of IMembers."""
name = Attribute(
"""The name for this roster.
Rosters are considered equal if they have the same name.""")
+ members = Attribute(
+ """An iterator over all the IMembers managed by this roster.""")
+
+ users = Attribute(
+ """An iterator over all the IUsers reachable by this roster.
+
+ This returns all the users for all the members managed by this roster.
+ """)
+
addresses = Attribute(
- """An iterator over all the addresses managed by this roster.""")
+ """An iterator over all the IAddresses reachable by this roster.
- def create(email_address, real_name=None):
- """Create an IAddress and return it.
+ This returns all the addresses for all the users for all the members
+ managed by this roster.
+ """)
- email_address is textual email address to add. real_name is the
- optional real name that gets associated with the email address.
+ def get_member(address):
+ """Return the IMember for the given address.
- Raises ExistingAddressError if address already exists.
+ 'address' is a text email address. If no matching member is found,
+ None is returned.
"""
diff --git a/Mailman/interfaces/rosterset.py b/Mailman/interfaces/rosterset.py
deleted file mode 100644
index 12f28bffa..000000000
--- a/Mailman/interfaces/rosterset.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# Copyright (C) 2007 by the Free Software Foundation, Inc.
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
-# USA.
-
-"""Interface for a collection of rosters."""
-
-from zope.interface import Interface, Attribute
-
-
-
-class IRosterSet(Interface):
- """A collection of IRosters."""
-
- serial = Attribute(
- """The unique integer serial number for this roster set.
-
- This is necessary to enforce the separation between the list storage
- and the user/roster storage. You should always reference a roster set
- indirectly through its serial number.""")
-
- rosters = Attribute(
- """An iterator over all the IRosters in this collection.""")
-
- def add(roster):
- """Add the IRoster to this collection.
-
- Does nothing if the roster is already a member of this collection.
- """
-
- def delete(roster):
- """Delete the IRoster from this collection.
-
- Does nothing if the roster is not a member of this collection.
- """
diff --git a/Mailman/interfaces/user.py b/Mailman/interfaces/user.py
index 6990eee4b..f7647597d 100644
--- a/Mailman/interfaces/user.py
+++ b/Mailman/interfaces/user.py
@@ -30,12 +30,23 @@ class IUser(Interface):
password = Attribute(
"""This user's password information.""")
- profile = Attribute(
- """The default IProfile for this user.""")
-
addresses = Attribute(
"""An iterator over all the IAddresses controlled by this user.""")
+ def register(address, real_name=None):
+ """Register the given email address and link it to this user.
+
+ In this case, 'address' is a text email address, not an IAddress
+ object. If real_name is not given, the empty string is used.
+
+ Raises AddressAlreadyLinkedError if this IAddress is already linked to
+ another user. If the corresponding IAddress already exists but is not
+ linked, then it is simply linked to the user, in which case
+ real_name is ignored.
+
+ Return the new IAddress object.
+ """
+
def link(address):
"""Link this user to the given IAddress.
@@ -57,3 +68,6 @@ class IUser(Interface):
'address' is a text email address. This method returns true if the
user controls the given email address, otherwise false.
"""
+
+ preferences = Attribute(
+ """This user's preferences.""")
diff --git a/Mailman/interfaces/usermanager.py b/Mailman/interfaces/usermanager.py
index 302fe9b60..f201b3591 100644
--- a/Mailman/interfaces/usermanager.py
+++ b/Mailman/interfaces/usermanager.py
@@ -33,30 +33,19 @@ class IUserManager(Interface):
IUsers in all IRosters.
"""
- def create_roster(name):
- """Create and return the named IRoster.
+ def create_user(address=None, real_name=None):
+ """Create and return an IUser.
- Raises RosterExistsError if the named roster already exists.
- """
-
- def get_roster(name):
- """Return the named IRoster.
-
- Raises NoSuchRosterError if the named roster doesnot yet exist.
- """
-
- def delete_roster(name):
- """Delete the named IRoster.
+ When address is given, an IAddress is also created and linked to the
+ new IUser object. If the address already exists, an
+ ExistingAddressError is raised. If the address exists but is already
+ linked to another user, an AddressAlreadyLinkedError is raised.
- Raises NoSuchRosterError if the named roster doesnot yet exist.
+ When real_name is given, the IUser's real_name is set to this string.
+ If an IAddress is also created and linked, its real_name is set to the
+ same string.
"""
- rosters = Attribute(
- """An iterator over all IRosters managed by this user manager.""")
-
- def create_user():
- """Create and return an IUser."""
-
def delete_user(user):
"""Delete the given IUser."""
@@ -69,14 +58,27 @@ class IUserManager(Interface):
users = Attribute(
"""An iterator over all the IUsers managed by this user manager.""")
- def create_rosterset():
- """Create and return a new IRosterSet.
+ def create_address(address, real_name=None):
+ """Create and return an unlinked IAddress object.
+
+ address is the text email address. If real_name is not given, it
+ defaults to the empty string. If the IAddress already exists an
+ ExistingAddressError is raised.
+ """
+
+ def delete_address(address):
+ """Delete the given IAddress object.
- IRosterSets manage groups of IRosters.
+ If this IAddress linked to a user, it is first unlinked before it is
+ deleted.
"""
- def delete_rosterset(rosterset):
- """Delete the given IRosterSet."""
+ def get_address(address):
+ """Find and return an IAddress.
+
+ 'address' is a text email address. None is returned if there is no
+ registered IAddress for the given text address.
+ """
- def get_rosterset(serial):
- """Return the IRosterSet that matches the serial number, or None."""
+ addresses = Attribute(
+ """An iterator over all the IAddresses managed by this manager.""")
diff --git a/Mailman/database/model/rosterset.py b/Mailman/testing/test_acknowledge.py
index f84b52c15..a40d0c9e4 100644
--- a/Mailman/database/model/rosterset.py
+++ b/Mailman/testing/test_acknowledge.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2006-2007 by the Free Software Foundation, Inc.
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
@@ -15,27 +15,18 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.
-from elixir import *
-from zope.interface import implements
+"""Doctest harness for testing message acknowledgment."""
-from Mailman.interfaces import IRosterSet
+import doctest
+import unittest
-ROSTER_KIND = 'Mailman.database.model.roster.Roster'
+options = (doctest.ELLIPSIS
+ | doctest.NORMALIZE_WHITESPACE
+ | doctest.REPORT_NDIFF)
-
-# Internal implementation of roster sets for use with mailing lists. These
-# are owned by the user storage.
-class RosterSet(Entity):
- implements(IRosterSet)
-
- has_field('name', Unicode)
- has_and_belongs_to_many('rosters', of_kind=ROSTER_KIND)
-
- def add(self, roster):
- if roster not in self.rosters:
- self.rosters.append(roster)
-
- def delete(self, roster):
- if roster in self.rosters:
- self.rosters.remove(roster)
+def test_suite():
+ suite = unittest.TestSuite()
+ suite.addTest(doctest.DocFileSuite('../docs/acknowledge.txt',
+ optionflags=options))
+ return suite
diff --git a/Mailman/testing/test_after_delivery.py b/Mailman/testing/test_after_delivery.py
new file mode 100644
index 000000000..ea4801b39
--- /dev/null
+++ b/Mailman/testing/test_after_delivery.py
@@ -0,0 +1,32 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Doctest harness for testing bookkeeping done after message delivery."""
+
+import doctest
+import unittest
+
+options = (doctest.ELLIPSIS
+ | doctest.NORMALIZE_WHITESPACE
+ | doctest.REPORT_NDIFF)
+
+
+def test_suite():
+ suite = unittest.TestSuite()
+ suite.addTest(doctest.DocFileSuite('../docs/after-delivery.txt',
+ optionflags=options))
+ return suite
diff --git a/Mailman/testing/test_avoid_duplicates.py b/Mailman/testing/test_avoid_duplicates.py
new file mode 100644
index 000000000..96599f1ed
--- /dev/null
+++ b/Mailman/testing/test_avoid_duplicates.py
@@ -0,0 +1,33 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Doctest harness for the AvoidDuplicates handler."""
+
+import os
+import doctest
+import unittest
+
+options = (doctest.ELLIPSIS
+ | doctest.NORMALIZE_WHITESPACE
+ | doctest.REPORT_NDIFF)
+
+
+def test_suite():
+ suite = unittest.TestSuite()
+ suite.addTest(doctest.DocFileSuite('../docs/avoid-duplicates.txt',
+ optionflags=options))
+ return suite
diff --git a/Mailman/testing/test_calc_recips.py b/Mailman/testing/test_calc_recips.py
new file mode 100644
index 000000000..7e876d428
--- /dev/null
+++ b/Mailman/testing/test_calc_recips.py
@@ -0,0 +1,32 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Doctest harness for testing the recipient calculation handler."""
+
+import doctest
+import unittest
+
+options = (doctest.ELLIPSIS
+ | doctest.NORMALIZE_WHITESPACE
+ | doctest.REPORT_NDIFF)
+
+
+def test_suite():
+ suite = unittest.TestSuite()
+ suite.addTest(doctest.DocFileSuite('../docs/calc-recips.txt',
+ optionflags=options))
+ return suite
diff --git a/Mailman/testing/test_mlist_rosters.py b/Mailman/testing/test_cleanse.py
index e8713b828..97ad9c46c 100644
--- a/Mailman/testing/test_mlist_rosters.py
+++ b/Mailman/testing/test_cleanse.py
@@ -15,16 +15,18 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.
-"""Doctest harness for the IMailingListRosters interface."""
+"""Doctest harness for the Cleanse handler."""
import doctest
import unittest
-options = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE
+options = (doctest.ELLIPSIS
+ | doctest.NORMALIZE_WHITESPACE
+ | doctest.REPORT_NDIFF)
def test_suite():
suite = unittest.TestSuite()
- suite.addTest(doctest.DocFileSuite('../docs/mlist-rosters.txt',
+ suite.addTest(doctest.DocFileSuite('../docs/cleanse.txt',
optionflags=options))
return suite
diff --git a/Mailman/testing/test_cook_headers.py b/Mailman/testing/test_cook_headers.py
new file mode 100644
index 000000000..ec8997858
--- /dev/null
+++ b/Mailman/testing/test_cook_headers.py
@@ -0,0 +1,35 @@
+# Copyright (C) 2007 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+# USA.
+
+"""Doctest harness for the CookHeaders handler."""
+
+import os
+import doctest
+import unittest
+
+options = (doctest.ELLIPSIS
+ | doctest.NORMALIZE_WHITESPACE
+ | doctest.REPORT_NDIFF)
+
+
+def test_suite():
+ suite = unittest.TestSuite()
+ for filename in ('ack-headers', 'cook-headers', 'subject-munging',
+ 'reply-to'):
+ path = os.path.join('..', 'docs', filename + '.txt')
+ suite.addTest(doctest.DocFileSuite(path, optionflags=options))
+ return suite
diff --git a/Mailman/testing/test_handlers.py b/Mailman/testing/test_handlers.py
index f4ad2ba4f..b44a1c2cc 100644
--- a/Mailman/testing/test_handlers.py
+++ b/Mailman/testing/test_handlers.py
@@ -39,9 +39,6 @@ from Mailman.testing.base import TestBase
from Mailman.Handlers import Acknowledge
from Mailman.Handlers import AfterDelivery
from Mailman.Handlers import Approve
-from Mailman.Handlers import CalcRecips
-from Mailman.Handlers import Cleanse
-from Mailman.Handlers import CookHeaders
from Mailman.Handlers import FileRecips
from Mailman.Handlers import Hold
from Mailman.Handlers import MimeDel
@@ -62,147 +59,6 @@ def password(cleartext):
-class TestAcknowledge(TestBase):
- def setUp(self):
- TestBase.setUp(self)
- # We're going to want to inspect this queue directory
- self._sb = Switchboard(config.VIRGINQUEUE_DIR)
- # Add a member
- self._mlist.addNewMember('aperson@example.org')
- self._mlist.personalize = False
-
- def tearDown(self):
- for f in os.listdir(config.VIRGINQUEUE_DIR):
- os.unlink(os.path.join(config.VIRGINQUEUE_DIR, f))
- TestBase.tearDown(self)
-
- def test_no_ack_msgdata(self):
- eq = self.assertEqual
- # Make sure there are no files in the virgin queue already
- eq(len(self._sb.files()), 0)
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- Acknowledge.process(self._mlist, msg,
- {'original_sender': 'aperson@example.org'})
- eq(len(self._sb.files()), 0)
-
- def test_no_ack_not_a_member(self):
- eq = self.assertEqual
- # Make sure there are no files in the virgin queue already
- eq(len(self._sb.files()), 0)
- msg = email.message_from_string("""\
-From: bperson@example.com
-
-""", Message.Message)
- Acknowledge.process(self._mlist, msg,
- {'original_sender': 'bperson@example.com'})
- eq(len(self._sb.files()), 0)
-
- def test_no_ack_sender(self):
- eq = self.assertEqual
- eq(len(self._sb.files()), 0)
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- Acknowledge.process(self._mlist, msg, {})
- eq(len(self._sb.files()), 0)
-
- def test_ack_no_subject(self):
- eq = self.assertEqual
- self._mlist.setMemberOption(
- 'aperson@example.org', config.AcknowledgePosts, 1)
- eq(len(self._sb.files()), 0)
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- Acknowledge.process(self._mlist, msg, {})
- files = self._sb.files()
- eq(len(files), 1)
- qmsg, qdata = self._sb.dequeue(files[0])
- # Check the .db file
- eq(qdata.get('listname'), '_xtest@example.com')
- eq(qdata.get('recips'), ['aperson@example.org'])
- eq(qdata.get('version'), 3)
- # Check the .pck
- eq(str(qmsg['subject']), '_xtest post acknowledgement')
- eq(qmsg['to'], 'aperson@example.org')
- eq(qmsg['from'], '_xtest-bounces@example.com')
- eq(qmsg.get_content_type(), 'text/plain')
- eq(qmsg.get_param('charset'), 'us-ascii')
- msgid = qmsg['message-id']
- self.failUnless(msgid.startswith('<mailman.'))
- self.failUnless(msgid.endswith('._xtest@example.com>'))
- eq(qmsg.get_payload(), """\
-Your message entitled
-
- (no subject)
-
-was successfully received by the _xtest mailing list.
-
-List info page: http://www.example.com/mailman/listinfo/_xtest@example.com
-Your preferences: http://www.example.com/mailman/options/_xtest@example.com/aperson%40example.org
-""")
- # Make sure we dequeued the only message
- eq(len(self._sb.files()), 0)
-
- def test_ack_with_subject(self):
- eq = self.assertEqual
- self._mlist.setMemberOption(
- 'aperson@example.org', config.AcknowledgePosts, 1)
- eq(len(self._sb.files()), 0)
- msg = email.message_from_string("""\
-From: aperson@example.org
-Subject: Wish you were here
-
-""", Message.Message)
- Acknowledge.process(self._mlist, msg, {})
- files = self._sb.files()
- eq(len(files), 1)
- qmsg, qdata = self._sb.dequeue(files[0])
- # Check the .db file
- eq(qdata.get('listname'), '_xtest@example.com')
- eq(qdata.get('recips'), ['aperson@example.org'])
- eq(qdata.get('version'), 3)
- # Check the .pck
- eq(str(qmsg['subject']), '_xtest post acknowledgement')
- eq(qmsg['to'], 'aperson@example.org')
- eq(qmsg['from'], '_xtest-bounces@example.com')
- eq(qmsg.get_content_type(), 'text/plain')
- eq(qmsg.get_param('charset'), 'us-ascii')
- msgid = qmsg['message-id']
- self.failUnless(msgid.startswith('<mailman.'))
- self.failUnless(msgid.endswith('._xtest@example.com>'))
- eq(qmsg.get_payload(), """\
-Your message entitled
-
- Wish you were here
-
-was successfully received by the _xtest mailing list.
-
-List info page: http://www.example.com/mailman/listinfo/_xtest@example.com
-Your preferences: http://www.example.com/mailman/options/_xtest@example.com/aperson%40example.org
-""")
- # Make sure we dequeued the only message
- eq(len(self._sb.files()), 0)
-
-
-
-class TestAfterDelivery(TestBase):
- # Both msg and msgdata are ignored
- def test_process(self):
- mlist = self._mlist
- last_post_time = mlist.last_post_time
- post_id = mlist.post_id
- AfterDelivery.process(mlist, None, None)
- self.failUnless(mlist.last_post_time > last_post_time)
- self.assertEqual(mlist.post_id, post_id + 1)
-
-
-
class TestApprove(TestBase):
def test_short_circuit(self):
msgdata = {'approved': 1}
@@ -279,511 +135,6 @@ X-BeenThere: %s
-class TestCalcRecips(TestBase):
- def setUp(self):
- TestBase.setUp(self)
- # Add a bunch of regular members
- mlist = self._mlist
- mlist.addNewMember('aperson@example.org')
- mlist.addNewMember('bperson@example.com')
- mlist.addNewMember('cperson@example.com')
- # And a bunch of digest members
- mlist.addNewMember('dperson@example.com', digest=1)
- mlist.addNewMember('eperson@example.com', digest=1)
- mlist.addNewMember('fperson@example.com', digest=1)
-
- def test_short_circuit(self):
- msgdata = {'recips': 1}
- rtn = CalcRecips.process(self._mlist, None, msgdata)
- # Not really a great test, but there's little else to assert
- self.assertEqual(rtn, None)
-
- def test_simple_path(self):
- msgdata = {}
- msg = email.message_from_string("""\
-From: dperson@example.com
-
-""", Message.Message)
- CalcRecips.process(self._mlist, msg, msgdata)
- self.failUnless(msgdata.has_key('recips'))
- recips = msgdata['recips']
- recips.sort()
- self.assertEqual(recips, ['aperson@example.org', 'bperson@example.com',
- 'cperson@example.com'])
-
- def test_exclude_sender(self):
- msgdata = {}
- msg = email.message_from_string("""\
-From: cperson@example.com
-
-""", Message.Message)
- self._mlist.setMemberOption('cperson@example.com',
- config.DontReceiveOwnPosts, 1)
- CalcRecips.process(self._mlist, msg, msgdata)
- self.failUnless(msgdata.has_key('recips'))
- recips = msgdata['recips']
- recips.sort()
- self.assertEqual(recips, ['aperson@example.org', 'bperson@example.com'])
-
- def test_urgent_moderator(self):
- self._mlist.mod_password = password('xxXXxx')
- msgdata = {}
- msg = email.message_from_string("""\
-From: dperson@example.com
-Urgent: xxXXxx
-
-""", Message.Message)
- CalcRecips.process(self._mlist, msg, msgdata)
- self.failUnless(msgdata.has_key('recips'))
- recips = msgdata['recips']
- recips.sort()
- self.assertEqual(recips, ['aperson@example.org', 'bperson@example.com',
- 'cperson@example.com', 'dperson@example.com',
- 'eperson@example.com', 'fperson@example.com'])
-
- def test_urgent_admin(self):
- self._mlist.mod_password = password('yyYYyy')
- self._mlist.password = password('xxXXxx')
- msgdata = {}
- msg = email.message_from_string("""\
-From: dperson@example.com
-Urgent: xxXXxx
-
-""", Message.Message)
- CalcRecips.process(self._mlist, msg, msgdata)
- self.failUnless(msgdata.has_key('recips'))
- recips = msgdata['recips']
- recips.sort()
- self.assertEqual(recips, ['aperson@example.org', 'bperson@example.com',
- 'cperson@example.com', 'dperson@example.com',
- 'eperson@example.com', 'fperson@example.com'])
-
- def test_urgent_reject(self):
- self._mlist.mod_password = password('yyYYyy')
- self._mlist.password = password('xxXXxx')
- msgdata = {}
- msg = email.message_from_string("""\
-From: dperson@example.com
-Urgent: zzZZzz
-
-""", Message.Message)
- self.assertRaises(Errors.RejectMessage,
- CalcRecips.process,
- self._mlist, msg, msgdata)
-
- # BAW: must test the do_topic_filters() path...
-
-
-
-class TestCleanse(TestBase):
- def setUp(self):
- TestBase.setUp(self)
-
- def test_simple_cleanse(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-From: aperson@example.org
-Approved: yes
-Urgent: indeed
-Reply-To: bperson@example.com
-Sender: asystem@example.com
-Return-Receipt-To: another@example.com
-Disposition-Notification-To: athird@example.com
-X-Confirm-Reading-To: afourth@example.com
-X-PMRQC: afifth@example.com
-Subject: a message to you
-
-""", Message.Message)
- Cleanse.process(self._mlist, msg, {})
- eq(msg['approved'], None)
- eq(msg['urgent'], None)
- eq(msg['return-receipt-to'], None)
- eq(msg['disposition-notification-to'], None)
- eq(msg['x-confirm-reading-to'], None)
- eq(msg['x-pmrqc'], None)
- eq(msg['from'], 'aperson@example.org')
- eq(msg['reply-to'], 'bperson@example.com')
- eq(msg['sender'], 'asystem@example.com')
- eq(msg['subject'], 'a message to you')
-
- def test_anon_cleanse(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-From: aperson@example.org
-Approved: yes
-Urgent: indeed
-Reply-To: bperson@example.com
-Sender: asystem@example.com
-Return-Receipt-To: another@example.com
-Disposition-Notification-To: athird@example.com
-X-Confirm-Reading-To: afourth@example.com
-X-PMRQC: afifth@example.com
-Subject: a message to you
-
-""", Message.Message)
- self._mlist.anonymous_list = 1
- Cleanse.process(self._mlist, msg, {})
- eq(msg['approved'], None)
- eq(msg['urgent'], None)
- eq(msg['return-receipt-to'], None)
- eq(msg['disposition-notification-to'], None)
- eq(msg['x-confirm-reading-to'], None)
- eq(msg['x-pmrqc'], None)
- eq(len(msg.get_all('from')), 1)
- eq(len(msg.get_all('reply-to')), 1)
- eq(msg['from'], '_xtest@example.com')
- eq(msg['reply-to'], '_xtest@example.com')
- eq(msg['sender'], None)
- eq(msg['subject'], 'a message to you')
-
-
-
-class TestCookHeaders(TestBase):
- def test_transform_noack_to_xack(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-X-Ack: yes
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {'noack': 1})
- eq(len(msg.get_all('x-ack')), 1)
- eq(msg['x-ack'], 'no')
-
- def test_original_sender(self):
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- msgdata = {}
- CookHeaders.process(self._mlist, msg, msgdata)
- self.assertEqual(msgdata.get('original_sender'), 'aperson@example.org')
-
- def test_no_original_sender(self):
- msg = email.message_from_string("""\
-Subject: about this message
-
-""", Message.Message)
- msgdata = {}
- CookHeaders.process(self._mlist, msg, msgdata)
- self.assertEqual(msgdata.get('original_sender'), '')
-
- def test_xbeenthere(self):
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(msg['x-beenthere'], '_xtest@example.com')
-
- def test_multiple_xbeentheres(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-From: aperson@example.org
-X-BeenThere: alist@another.example.com
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- eq(len(msg.get_all('x-beenthere')), 2)
- beentheres = msg.get_all('x-beenthere')
- beentheres.sort()
- eq(beentheres, ['_xtest@example.com', 'alist@another.example.com'])
-
- def test_nonexisting_mmversion(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- eq(msg['x-mailman-version'], Version.VERSION)
-
- def test_existing_mmversion(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-From: aperson@example.org
-X-Mailman-Version: 3000
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- eq(len(msg.get_all('x-mailman-version')), 1)
- eq(msg['x-mailman-version'], '3000')
-
- def test_nonexisting_precedence(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- eq(msg['precedence'], 'list')
-
- def test_existing_precedence(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-From: aperson@example.org
-Precedence: junk
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- eq(len(msg.get_all('precedence')), 1)
- eq(msg['precedence'], 'junk')
-
- def test_subject_munging_no_subject(self):
- self._mlist.subject_prefix = '[XTEST] '
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- msgdata = {}
- CookHeaders.process(self._mlist, msg, msgdata)
- self.assertEqual(msgdata.get('origsubj'), '')
- self.assertEqual(str(msg['subject']), '[XTEST] (no subject)')
-
- def test_subject_munging(self):
- self._mlist.subject_prefix = '[XTEST] '
- msg = email.message_from_string("""\
-From: aperson@example.org
-Subject: About Mailman...
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(msg['subject'], '[XTEST] About Mailman...')
-
- def test_no_subject_munging_for_digests(self):
- self._mlist.subject_prefix = '[XTEST] '
- msg = email.message_from_string("""\
-From: aperson@example.org
-Subject: About Mailman...
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {'isdigest': 1})
- self.assertEqual(msg['subject'], 'About Mailman...')
-
- def test_no_subject_munging_for_fasttrack(self):
- self._mlist.subject_prefix = '[XTEST] '
- msg = email.message_from_string("""\
-From: aperson@example.org
-Subject: About Mailman...
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {'_fasttrack': 1})
- self.assertEqual(msg['subject'], 'About Mailman...')
-
- def test_no_subject_munging_has_prefix(self):
- self._mlist.subject_prefix = '[XTEST] '
- msg = email.message_from_string("""\
-From: aperson@example.org
-Subject: Re: [XTEST] About Mailman...
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(msg['subject'], 'Re: [XTEST] About Mailman...')
-
- def test_subject_munging_i18n(self):
- self._mlist.subject_prefix = '[XTEST]'
- msg = Message.Message()
- msg['Subject'] = '=?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?='
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(unicode(msg['subject']),
- u'[XTEST] \u30e1\u30fc\u30eb\u30de\u30f3')
- self.assertEqual(msg['subject'],
- '[XTEST] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=')
- self._mlist.subject_prefix = '[XTEST %d]'
- self._mlist.post_id = 456
- msg = Message.Message()
- msg['Subject'] = '=?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?='
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(unicode(msg['subject']),
- u'[XTEST 456] \u30e1\u30fc\u30eb\u30de\u30f3')
- self.assertEqual(msg['subject'],
- '[XTEST 456] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=')
- msg = Message.Message()
- msg['Subject'
- ] = 'Re: [XTEST 123] =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?='
- CookHeaders.process(self._mlist, msg, {})
- # next code suceeds if python email patch tracker #1681333 is applied.
- #self.assertEqual(unicode(msg['subject']),
- # u'[XTEST 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3')
- self.assertEqual(msg['subject'],
- '[XTEST 456] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?=')
-
- def test_subject_munging_prefix_number(self):
- self._mlist.subject_prefix = '[XTEST %d]'
- self._mlist.post_id = 456
- msg = Message.Message()
- msg['Subject'] = 'About Mailman...'
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(msg['subject'], '[XTEST 456] About Mailman...')
- msg = Message.Message()
- msg['Subject'] = 'Re: [XTEST 123] About Mailman...'
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(msg['subject'], '[XTEST 456] Re: About Mailman...')
-
- def test_subject_munging_prefix_newstyle(self):
- self._mlist.subject_prefix = '[XTEST]'
- config.OLD_STYLE_PREFIXING = False
- msg = Message.Message()
- msg['Subject'] = 'Re: [XTEST] About Mailman...'
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(msg['subject'], '[XTEST] Re: About Mailman...')
-
- def test_subject_munging_prefix_crooked(self):
- # In this test case, we get an extra space between the prefix and
- # the original subject. It's because the original is crooked.
- # Note that isubject starting by '\n ' is generated by some version of
- # Eudora Japanese edition.
- self._mlist.subject_prefix = '[XTEST]'
- msg = Message.Message()
- msg['Subject'] = '\n About Mailman...'
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(str(msg['subject']), '[XTEST] About Mailman...')
- del msg['subject']
- msg['Subject'] = '\n =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?='
- CookHeaders.process(self._mlist, msg, {})
- self.assertEqual(str(msg['subject']),
- '[XTEST] =?iso-2022-jp?b?IBskQiVhITwlayVeJXMbKEI=?=')
-
- def test_reply_to_list(self):
- eq = self.assertEqual
- mlist = self._mlist
- mlist.reply_goes_to_list = 1
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- CookHeaders.process(mlist, msg, {})
- eq(msg['reply-to'], '_xtest@example.com')
- eq(msg.get_all('reply-to'), ['_xtest@example.com'])
-
- def test_reply_to_list_with_strip(self):
- eq = self.assertEqual
- mlist = self._mlist
- mlist.reply_goes_to_list = 1
- mlist.first_strip_reply_to = 1
- msg = email.message_from_string("""\
-From: aperson@example.org
-Reply-To: bperson@example.com
-
-""", Message.Message)
- CookHeaders.process(mlist, msg, {})
- eq(msg['reply-to'], '_xtest@example.com')
- eq(msg.get_all('reply-to'), ['_xtest@example.com'])
-
- def test_reply_to_explicit(self):
- eq = self.assertEqual
- mlist = self._mlist
- mlist.reply_goes_to_list = 2
- mlist.reply_to_address = 'mlist@example.com'
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- CookHeaders.process(mlist, msg, {})
- eq(msg['reply-to'], 'mlist@example.com')
- eq(msg.get_all('reply-to'), ['mlist@example.com'])
-
- def test_reply_to_explicit_with_strip(self):
- eq = self.assertEqual
- mlist = self._mlist
- mlist.reply_goes_to_list = 2
- mlist.first_strip_reply_to = 1
- mlist.reply_to_address = 'mlist@example.com'
- msg = email.message_from_string("""\
-From: aperson@example.org
-Reply-To: bperson@example.com
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- eq(msg['reply-to'], 'mlist@example.com')
- eq(msg.get_all('reply-to'), ['mlist@example.com'])
-
- def test_reply_to_extends_to_list(self):
- eq = self.assertEqual
- mlist = self._mlist
- mlist.reply_goes_to_list = 1
- mlist.first_strip_reply_to = 0
- msg = email.message_from_string("""\
-From: aperson@example.org
-Reply-To: bperson@example.com
-
-""", Message.Message)
- CookHeaders.process(mlist, msg, {})
- eq(msg['reply-to'], 'bperson@example.com, _xtest@example.com')
-
- def test_reply_to_extends_to_explicit(self):
- eq = self.assertEqual
- mlist = self._mlist
- mlist.reply_goes_to_list = 2
- mlist.first_strip_reply_to = 0
- mlist.reply_to_address = 'mlist@example.com'
- msg = email.message_from_string("""\
-From: aperson@example.org
-Reply-To: bperson@example.com
-
-""", Message.Message)
- CookHeaders.process(mlist, msg, {})
- eq(msg['reply-to'], 'mlist@example.com, bperson@example.com')
-
- def test_list_headers_nolist(self):
- eq = self.assertEqual
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {'_nolist': 1})
- eq(msg['list-id'], None)
- eq(msg['list-help'], None)
- eq(msg['list-unsubscribe'], None)
- eq(msg['list-subscribe'], None)
- eq(msg['list-post'], None)
- eq(msg['list-archive'], None)
-
- def test_list_headers(self):
- eq = self.assertEqual
- self._mlist.archive = 1
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- oldval = config.DEFAULT_URL_HOST
- config.DEFAULT_URL_HOST = 'www.example.com'
- try:
- CookHeaders.process(self._mlist, msg, {})
- finally:
- config.DEFAULT_URL_HOST = oldval
- eq(msg['list-id'], '<_xtest.example.com>')
- eq(msg['list-help'], '<mailto:_xtest-request@example.com?subject=help>')
- eq(msg['list-unsubscribe'],
- '<http://www.example.com/mailman/listinfo/_xtest@example.com>,'
- '\n\t<mailto:_xtest-request@example.com?subject=unsubscribe>')
- eq(msg['list-subscribe'],
- '<http://www.example.com/mailman/listinfo/_xtest@example.com>,'
- '\n\t<mailto:_xtest-request@example.com?subject=subscribe>')
- eq(msg['list-post'], '<mailto:_xtest@example.com>')
- eq(msg['list-archive'],
- '<http://www.example.com/pipermail/_xtest@example.com>')
-
- def test_list_headers_with_description(self):
- eq = self.assertEqual
- self._mlist.archive = 1
- self._mlist.description = 'A Test List'
- msg = email.message_from_string("""\
-From: aperson@example.org
-
-""", Message.Message)
- CookHeaders.process(self._mlist, msg, {})
- eq(unicode(msg['list-id']), u'A Test List <_xtest.example.com>')
- eq(msg['list-help'], '<mailto:_xtest-request@example.com?subject=help>')
- eq(msg['list-unsubscribe'],
- '<http://www.example.com/mailman/listinfo/_xtest@example.com>,'
- '\n\t<mailto:_xtest-request@example.com?subject=unsubscribe>')
- eq(msg['list-subscribe'],
- '<http://www.example.com/mailman/listinfo/_xtest@example.com>,'
- '\n\t<mailto:_xtest-request@example.com?subject=subscribe>')
- eq(msg['list-post'], '<mailto:_xtest@example.com>')
-
-
-
class TestFileRecips(TestBase):
def test_short_circuit(self):
msgdata = {'recips': 1}
@@ -1700,12 +1051,7 @@ Mailman rocks!
def test_suite():
suite = unittest.TestSuite()
- suite.addTest(unittest.makeSuite(TestAcknowledge))
- suite.addTest(unittest.makeSuite(TestAfterDelivery))
suite.addTest(unittest.makeSuite(TestApprove))
- suite.addTest(unittest.makeSuite(TestCalcRecips))
- suite.addTest(unittest.makeSuite(TestCleanse))
- suite.addTest(unittest.makeSuite(TestCookHeaders))
suite.addTest(unittest.makeSuite(TestFileRecips))
suite.addTest(unittest.makeSuite(TestHold))
suite.addTest(unittest.makeSuite(TestMimeDel))
diff --git a/Mailman/testing/test_membership.py b/Mailman/testing/test_membership.py
index 8e8285034..8a25287ac 100644
--- a/Mailman/testing/test_membership.py
+++ b/Mailman/testing/test_membership.py
@@ -19,6 +19,7 @@
import os
import time
+import doctest
import unittest
from Mailman import MailList
@@ -30,6 +31,8 @@ from Mailman.UserDesc import UserDesc
from Mailman.configuration import config
from Mailman.testing.base import TestBase
+OPTIONFLAGS = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE
+
def password(cleartext):
@@ -387,6 +390,6 @@ class TestMembers(TestBase):
def test_suite():
suite = unittest.TestSuite()
- suite.addTest(unittest.makeSuite(TestNoMembers))
- suite.addTest(unittest.makeSuite(TestMembers))
+ suite.addTest(doctest.DocFileSuite('../docs/membership.txt',
+ optionflags=OPTIONFLAGS))
return suite
diff --git a/Mailman/testing/test_user.py b/Mailman/testing/test_user.py
index 1c075a164..59b686795 100644
--- a/Mailman/testing/test_user.py
+++ b/Mailman/testing/test_user.py
@@ -20,7 +20,9 @@
import doctest
import unittest
-options = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE
+options = (doctest.ELLIPSIS
+ | doctest.NORMALIZE_WHITESPACE
+ | doctest.REPORT_NDIFF)
def test_suite():