summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Mailman/Bouncer.py25
-rw-r--r--Mailman/Handlers/CookHeaders.py3
-rw-r--r--Mailman/Handlers/Hold.py17
-rw-r--r--Mailman/Handlers/Replybot.py8
-rw-r--r--Mailman/Handlers/Scrubber.py6
-rw-r--r--Mailman/Handlers/ToDigest.py2
-rw-r--r--Mailman/MailList.py239
-rw-r--r--Mailman/Queue/CommandRunner.py3
-rw-r--r--Mailman/Utils.py28
-rw-r--r--Mailman/app/archiving.py36
-rw-r--r--Mailman/app/bounces.py163
-rw-r--r--Mailman/app/lifecycle.py2
-rw-r--r--Mailman/app/membership.py2
-rw-r--r--Mailman/app/replybot.py89
-rw-r--r--Mailman/database/listmanager.py30
-rw-r--r--Mailman/database/model/mailinglist.py48
-rw-r--r--Mailman/database/model/requests.py12
-rw-r--r--Mailman/docs/acknowledge.txt2
-rw-r--r--Mailman/docs/bounces.txt5
-rw-r--r--Mailman/docs/cook-headers.txt2
-rw-r--r--Mailman/docs/hold.txt2
-rw-r--r--Mailman/docs/listmanager.txt8
-rw-r--r--Mailman/docs/replybot.txt4
-rw-r--r--Mailman/docs/requests.txt2
-rw-r--r--Mailman/docs/scrubber.txt3
-rw-r--r--TODO.txt2
26 files changed, 424 insertions, 319 deletions
diff --git a/Mailman/Bouncer.py b/Mailman/Bouncer.py
index 99093b334..f4a8ce90f 100644
--- a/Mailman/Bouncer.py
+++ b/Mailman/Bouncer.py
@@ -247,28 +247,3 @@ class Bouncer:
msg.send(self)
info.noticesleft -= 1
info.lastnotice = time.localtime()[:3]
-
- def bounce_message(self, msg, e=None):
- # Bounce a message back to the sender, with an error message if
- # provided in the exception argument.
- sender = msg.get_sender()
- subject = msg.get('subject', _('(no subject)'))
- subject = Utils.oneline(subject,
- Utils.GetCharSet(self.preferred_language))
- if e is None:
- notice = _('[No bounce details are available]')
- else:
- notice = _(e.notice)
- # Currently we always craft bounces as MIME messages.
- bmsg = Message.UserNotification(msg.get_sender(),
- self.owner_address,
- subject,
- lang=self.preferred_language)
- # BAW: Be sure you set the type before trying to attach, or you'll get
- # a MultipartConversionError.
- bmsg.set_type('multipart/mixed')
- txt = MIMEText(notice,
- _charset=Utils.GetCharSet(self.preferred_language))
- bmsg.attach(txt)
- bmsg.attach(MIMEMessage(msg))
- bmsg.send(self)
diff --git a/Mailman/Handlers/CookHeaders.py b/Mailman/Handlers/CookHeaders.py
index 5634fa23e..82c87c20b 100644
--- a/Mailman/Handlers/CookHeaders.py
+++ b/Mailman/Handlers/CookHeaders.py
@@ -26,6 +26,7 @@ from email.Utils import parseaddr, formataddr, getaddresses
from Mailman import Utils
from Mailman import Version
+from Mailman.app.archiving import get_base_archive_url
from Mailman.configuration import config
from Mailman.constants import ReplyToMunging
from Mailman.i18n import _
@@ -207,7 +208,7 @@ def process(mlist, msg, msgdata):
headers['List-Post'] = '<mailto:%s>' % mlist.posting_address
# Add this header if we're archiving
if mlist.archive:
- archiveurl = mlist.GetBaseArchiveURL()
+ archiveurl = get_base_archive_url(mlist)
if archiveurl.endswith('/'):
archiveurl = archiveurl[:-1]
headers['List-Archive'] = '<%s>' % archiveurl
diff --git a/Mailman/Handlers/Hold.py b/Mailman/Handlers/Hold.py
index 75f7b6386..2e6eeb4ad 100644
--- a/Mailman/Handlers/Hold.py
+++ b/Mailman/Handlers/Hold.py
@@ -43,7 +43,10 @@ from Mailman import Errors
from Mailman import Message
from Mailman import Utils
from Mailman import i18n
+from Mailman.app.bounces import (
+ has_explicit_destination, has_matching_bounce_header)
from Mailman.app.moderator import hold_message
+from Mailman.app.replybot import autorespond_to_sender
from Mailman.configuration import config
from Mailman.interfaces import IPendable
@@ -88,7 +91,7 @@ class Administrivia(Errors.HoldMessage):
reason = _('Message may contain administrivia')
def rejection_notice(self, mlist):
- listurl = mlist.GetScriptURL('listinfo', absolute=1)
+ listurl = mlist.script_url('listinfo')
request = mlist.request_address
return _("""Please do *not* post administrative requests to the mailing
list. If you wish to subscribe, visit $listurl or send a message with the
@@ -171,7 +174,7 @@ def process(mlist, msg, msgdata):
# Implicit destination? Note that message originating from the Usenet
# side of the world should never be checked for implicit destination.
if mlist.require_explicit_destination and \
- not mlist.HasExplicitDest(msg) and \
+ not has_explicit_destination(mlist, msg) and \
not msgdata.get('fromusenet'):
# then
hold_for_approval(mlist, msg, msgdata, ImplicitDestination)
@@ -179,7 +182,7 @@ def process(mlist, msg, msgdata):
#
# Suspicious headers?
if mlist.bounce_matching_headers:
- triggered = mlist.hasMatchingHeader(msg)
+ triggered = has_matching_bounce_header(mlist, msg)
if triggered:
# TBD: Darn - can't include the matching line for the admin
# message because the info would also go to the sender
@@ -239,7 +242,7 @@ def hold_for_approval(mlist, msg, msgdata, exc):
'reason' : _(reason),
'sender' : sender,
'subject' : usersubject,
- 'admindb_url': mlist.GetScriptURL('admindb', absolute=1),
+ 'admindb_url': mlist.script_url('admindb'),
}
# We may want to send a notification to the original sender too
fromusenet = msgdata.get('fromusenet')
@@ -259,10 +262,10 @@ def hold_for_approval(mlist, msg, msgdata, exc):
member = mlist.members.get_member(sender)
lang = (member.preferred_language if member else mlist.preferred_language)
if not fromusenet and ackp(msg) and mlist.respond_to_post_requests and \
- mlist.autorespondToSender(sender, lang):
+ autorespond_to_sender(mlist, sender, lang):
# Get a confirmation token
- d['confirmurl'] = '%s/%s' % (mlist.GetScriptURL('confirm', absolute=1),
- token)
+ d['confirmurl'] = '%s/%s' % (
+ mlist.script_url('confirm'), token)
lang = msgdata.get('lang', lang)
subject = _('Your message to $listname awaits moderator approval')
text = Utils.maketext('postheld.txt', d, lang=lang, mlist=mlist)
diff --git a/Mailman/Handlers/Replybot.py b/Mailman/Handlers/Replybot.py
index 18fc83ced..7017d9dd5 100644
--- a/Mailman/Handlers/Replybot.py
+++ b/Mailman/Handlers/Replybot.py
@@ -19,6 +19,7 @@
import time
import logging
+import datetime
from string import Template
@@ -29,6 +30,7 @@ from Mailman.i18n import _
log = logging.getLogger('mailman.error')
__i18n_templates__ = True
+NODELTA = datetime.timedelta()
@@ -63,7 +65,7 @@ def process(mlist, msg, msgdata):
sender = msg.get_sender()
now = time.time()
graceperiod = mlist.autoresponse_graceperiod
- if graceperiod > 0 and ack <> 'yes':
+ if graceperiod > NODELTA and ack <> 'yes':
if toadmin:
quiet_until = mlist.admin_responses.get(sender, 0)
elif torequest:
@@ -79,7 +81,7 @@ def process(mlist, msg, msgdata):
'Auto-response for your message to the "$realname" mailing list')
# Do string interpolation into the autoresponse text
d = dict(listname = realname,
- listurl = mlist.GetScriptURL('listinfo'),
+ listurl = mlist.script_url('listinfo'),
requestemail = mlist.request_address,
owneremail = mlist.owner_address,
)
@@ -98,7 +100,7 @@ def process(mlist, msg, msgdata):
outmsg['X-Ack'] = 'No'
outmsg.send(mlist)
# update the grace period database
- if graceperiod > 0:
+ if graceperiod > NODELTA:
# graceperiod is in days, we need # of seconds
quiet_until = now + graceperiod * 24 * 60 * 60
if toadmin:
diff --git a/Mailman/Handlers/Scrubber.py b/Mailman/Handlers/Scrubber.py
index a70cb0c71..655742899 100644
--- a/Mailman/Handlers/Scrubber.py
+++ b/Mailman/Handlers/Scrubber.py
@@ -38,6 +38,7 @@ from Mailman import LockFile
from Mailman import Message
from Mailman import Utils
from Mailman.Errors import DiscardMessage
+from Mailman.app.archiving import get_base_archive_url
from Mailman.configuration import config
from Mailman.i18n import _
@@ -388,7 +389,8 @@ def makedirs(dir):
def save_attachment(mlist, msg, dir, filter_html=True):
- fsdir = os.path.join(mlist.archive_dir(), dir)
+ fsdir = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR,
+ mlist.fqdn_listname, dir)
makedirs(fsdir)
# Figure out the attachment type and get the decoded data
decodedpayload = msg.get_payload(decode=True)
@@ -496,7 +498,7 @@ def save_attachment(mlist, msg, dir, filter_html=True):
fp.write(decodedpayload)
fp.close()
# Now calculate the url
- baseurl = mlist.GetBaseArchiveURL()
+ baseurl = get_base_archive_url(mlist)
# Private archives will likely have a trailing slash. Normalize.
if baseurl[-1] <> '/':
baseurl += '/'
diff --git a/Mailman/Handlers/ToDigest.py b/Mailman/Handlers/ToDigest.py
index 5fd08852f..7e2dcc6d2 100644
--- a/Mailman/Handlers/ToDigest.py
+++ b/Mailman/Handlers/ToDigest.py
@@ -179,7 +179,7 @@ def send_i18n_digests(mlist, mboxfp):
'masthead.txt',
{'real_name' : mlist.real_name,
'got_list_email': mlist.posting_address,
- 'got_listinfo_url': mlist.GetScriptURL('listinfo', absolute=1),
+ 'got_listinfo_url': mlist.script_url('listinfo'),
'got_request_email': mlist.request_address,
'got_owner_email': mlist.owner_address,
}, mlist=mlist)
diff --git a/Mailman/MailList.py b/Mailman/MailList.py
index 84b098ae6..900daaa11 100644
--- a/Mailman/MailList.py
+++ b/Mailman/MailList.py
@@ -58,9 +58,7 @@ from Mailman.interfaces import *
# Base classes
from Mailman.Archiver import Archiver
from Mailman.Bouncer import Bouncer
-from Mailman.Deliverer import Deliverer
from Mailman.Digester import Digester
-from Mailman.HTMLFormatter import HTMLFormatter
from Mailman.SecurityManager import SecurityManager
# GUI components package
@@ -85,8 +83,7 @@ slog = logging.getLogger('mailman.subscribe')
# Use mixins here just to avoid having any one chunk be too large.
-class MailList(object, HTMLFormatter, Deliverer,
- Archiver, Digester, SecurityManager, Bouncer):
+class MailList(object, Archiver, Digester, SecurityManager, Bouncer):
implements(
IMailingList,
@@ -165,50 +162,6 @@ class MailList(object, HTMLFormatter, Deliverer,
- # IMailingListAddresses
-
- @property
- def posting_address(self):
- return self.fqdn_listname
-
- @property
- def noreply_address(self):
- return '%s@%s' % (config.NO_REPLY_ADDRESS, self.host_name)
-
- @property
- def owner_address(self):
- return '%s-owner@%s' % (self.list_name, self.host_name)
-
- @property
- def request_address(self):
- return '%s-request@%s' % (self.list_name, self.host_name)
-
- @property
- def bounces_address(self):
- return '%s-bounces@%s' % (self.list_name, self.host_name)
-
- @property
- def join_address(self):
- return '%s-join@%s' % (self.list_name, self.host_name)
-
- @property
- def leave_address(self):
- return '%s-leave@%s' % (self.list_name, self.host_name)
-
- @property
- def subscribe_address(self):
- return '%s-subscribe@%s' % (self.list_name, self.host_name)
-
- @property
- def unsubscribe_address(self):
- return '%s-unsubscribe@%s' % (self.list_name, self.host_name)
-
- def confirm_address(self, cookie):
- local_part = Template(config.VERP_CONFIRM_FORMAT).safe_substitute(
- address = '%s-confirm' % self.list_name,
- cookie = cookie)
- return '%s@%s' % (local_part, self.host_name)
-
def GetConfirmJoinSubject(self, listname, cookie):
if config.VERP_CONFIRMATIONS and cookie:
cset = i18n.get_translation().charset() or \
@@ -393,9 +346,9 @@ class MailList(object, HTMLFormatter, Deliverer,
invitee = userdesc.address
Utils.ValidateEmail(invitee)
# check for banned address
- pattern = self.GetBannedPattern(invitee)
+ pattern = Utils.get_pattern(invitee, self.ban_list)
if pattern:
- raise Errors.MembershipIsBanned, pattern
+ raise Errors.MembershipIsBanned(pattern)
# Hack alert! Squirrel away a flag that only invitations have, so
# that we can do something slightly different when an invitation
# subscription is confirmed. In those cases, we don't need further
@@ -471,7 +424,7 @@ class MailList(object, HTMLFormatter, Deliverer,
raise Errors.InvalidEmailAddress
realname = self.real_name
# Is the subscribing address banned from this list?
- pattern = self.GetBannedPattern(email)
+ pattern = Utils.get_pattern(email, self.ban_list)
if pattern:
vlog.error('%s banned subscription: %s (matched: %s)',
realname, email, pattern)
@@ -588,7 +541,7 @@ class MailList(object, HTMLFormatter, Deliverer,
# Don't allow changing to a banned address. MAS: maybe we should
# unsubscribe the oldaddr too just for trying, but that's probably
# too harsh.
- pattern = self.GetBannedPattern(newaddr)
+ pattern = Utils.get_pattern(newaddr, self.ban_list)
if pattern:
vlog.error('%s banned address change: %s -> %s (matched: %s)',
realname, oldaddr, newaddr, pattern)
@@ -630,7 +583,7 @@ class MailList(object, HTMLFormatter, Deliverer,
# confirmation was mailed. MAS: If it's global change should we just
# skip this list and proceed to the others? For now we'll throw the
# exception.
- pattern = self.GetBannedPattern(newaddr)
+ pattern = Utils.get_pattern(newaddr, self.ban_list)
if pattern:
raise Errors.MembershipIsBanned, pattern
# It's possible they were a member of this list, but choose to change
@@ -655,7 +608,7 @@ class MailList(object, HTMLFormatter, Deliverer,
if not mlist.isMember(oldaddr):
continue
# If new address is banned from this list, just skip it.
- if mlist.GetBannedPattern(newaddr):
+ if Utils.get_pattern(newaddr, mlist.ban_list):
continue
mlist.Lock()
try:
@@ -859,194 +812,18 @@ class MailList(object, HTMLFormatter, Deliverer,
#
# Miscellaneous stuff
#
- def HasExplicitDest(self, msg):
- """True if list name or any acceptable_alias is included among the
- addresses in the recipient headers.
- """
- # This is the list's full address.
- recips = []
- # Check all recipient addresses against the list's explicit addresses,
- # specifically To: Cc: and Resent-to:
- to = []
- for header in ('to', 'cc', 'resent-to', 'resent-cc'):
- to.extend(getaddresses(msg.get_all(header, [])))
- for fullname, addr in to:
- # It's possible that if the header doesn't have a valid RFC 2822
- # value, we'll get None for the address. So skip it.
- if addr is None:
- continue
- addr = addr.lower()
- localpart = addr.split('@')[0]
- if (# TBD: backwards compatibility: deprecated
- localpart == self.list_name or
- # exact match against the complete list address
- addr == self.fqdn_listname):
- return True
- recips.append((addr, localpart))
- # Helper function used to match a pattern against an address.
- def domatch(pattern, addr):
- try:
- if re.match(pattern, addr, re.IGNORECASE):
- return True
- except re.error:
- # The pattern is a malformed regexp -- try matching safely,
- # with all non-alphanumerics backslashed:
- if re.match(re.escape(pattern), addr, re.IGNORECASE):
- return True
- return False
- # Here's the current algorithm for matching acceptable_aliases:
- #
- # 1. If the pattern does not have an `@' in it, we first try matching
- # it against just the localpart. This was the behavior prior to
- # 2.0beta3, and is kept for backwards compatibility. (deprecated).
- #
- # 2. If that match fails, or the pattern does have an `@' in it, we
- # try matching against the entire recip address.
- aliases = self.acceptable_aliases.splitlines()
- for addr, localpart in recips:
- for alias in aliases:
- stripped = alias.strip()
- if not stripped:
- # Ignore blank or empty lines
- continue
- if '@' not in stripped and domatch(stripped, localpart):
- return True
- if domatch(stripped, addr):
- return True
- return False
-
- def parse_matching_header_opt(self):
- """Return a list of triples [(field name, regex, line), ...]."""
- # - Blank lines and lines with '#' as first char are skipped.
- # - Leading whitespace in the matchexp is trimmed - you can defeat
- # that by, eg, containing it in gratuitous square brackets.
- all = []
- for line in self.bounce_matching_headers.split('\n'):
- line = line.strip()
- # Skip blank lines and lines *starting* with a '#'.
- if not line or line[0] == "#":
- continue
- i = line.find(':')
- if i < 0:
- # This didn't look like a header line. BAW: should do a
- # better job of informing the list admin.
- clog.error('bad bounce_matching_header line: %s\n%s',
- self.real_name, line)
- else:
- header = line[:i]
- value = line[i+1:].lstrip()
- try:
- cre = re.compile(value, re.IGNORECASE)
- except re.error, e:
- # The regexp was malformed. BAW: should do a better
- # job of informing the list admin.
- clog.error("""\
-bad regexp in bounce_matching_header line: %s
-\n%s (cause: %s)""", self.real_name, value, e)
- else:
- all.append((header, cre, line))
- return all
-
- def hasMatchingHeader(self, msg):
- """Return true if named header field matches a regexp in the
- bounce_matching_header list variable.
-
- Returns constraint line which matches or empty string for no
- matches.
- """
- for header, cre, line in self.parse_matching_header_opt():
- for value in msg.get_all(header, []):
- if cre.search(value):
- return line
- return 0
-
- def autorespondToSender(self, sender, lang=None):
- """Return true if Mailman should auto-respond to this sender.
-
- This is only consulted for messages sent to the -request address, or
- for posting hold notifications, and serves only as a safety value for
- mail loops with email 'bots.
- """
- # language setting
- if lang == None:
- lang = self.preferred_language
- i18n.set_language(lang)
- # No limit
- if config.MAX_AUTORESPONSES_PER_DAY == 0:
- return 1
- today = time.localtime()[:3]
- info = self.hold_and_cmd_autoresponses.get(sender)
- if info is None or info[0] <> today:
- # First time we've seen a -request/post-hold for this sender
- self.hold_and_cmd_autoresponses[sender] = (today, 1)
- # BAW: no check for MAX_AUTORESPONSES_PER_DAY <= 1
- return 1
- date, count = info
- if count < 0:
- # They've already hit the limit for today.
- vlog.info('-request/hold autoresponse discarded for: %s', sender)
- return 0
- if count >= config.MAX_AUTORESPONSES_PER_DAY:
- vlog.info('-request/hold autoresponse limit hit for: %s', sender)
- self.hold_and_cmd_autoresponses[sender] = (today, -1)
- # Send this notification message instead
- text = Utils.maketext(
- 'nomoretoday.txt',
- {'sender' : sender,
- 'listname': self.fqdn_listname,
- 'num' : count,
- 'owneremail': self.GetOwnerEmail(),
- },
- lang=lang)
- msg = Message.UserNotification(
- sender, self.GetOwnerEmail(),
- _('Last autoresponse notification for today'),
- text, lang=lang)
- msg.send(self)
- return 0
- self.hold_and_cmd_autoresponses[sender] = (today, count+1)
- return 1
-
- def GetBannedPattern(self, email):
- """Returns matched entry in ban_list if email matches.
- Otherwise returns None.
- """
- return self.ban_list and self.GetPattern(email, self.ban_list)
def HasAutoApprovedSender(self, sender):
"""Returns True and logs if sender matches address or pattern
in subscribe_auto_approval. Otherwise returns False.
"""
auto_approve = False
- if self.GetPattern(sender, self.subscribe_auto_approval):
+ if Utils.get_pattern(sender, self.subscribe_auto_approval):
auto_approve = True
vlog.info('%s: auto approved subscribe from %s',
self.internal_name(), sender)
return auto_approve
- def GetPattern(self, email, pattern_list):
- """Returns matched entry in pattern_list if email matches.
- Otherwise returns None.
- """
- matched = None
- for pattern in pattern_list:
- if pattern.startswith('^'):
- # This is a regular expression match
- try:
- if re.search(pattern, email, re.IGNORECASE):
- matched = pattern
- break
- except re.error:
- # BAW: we should probably remove this pattern
- pass
- else:
- # Do the comparison case insensitively
- if pattern.lower() == email.lower():
- matched = pattern
- break
- return matched
-
-
#
# Multilingual (i18n) support
diff --git a/Mailman/Queue/CommandRunner.py b/Mailman/Queue/CommandRunner.py
index 30f1e9bdf..2df28e312 100644
--- a/Mailman/Queue/CommandRunner.py
+++ b/Mailman/Queue/CommandRunner.py
@@ -36,6 +36,7 @@ from Mailman import Message
from Mailman import Utils
from Mailman.Handlers import Replybot
from Mailman.Queue.Runner import Runner
+from Mailman.app.replybot import autorespond_to_sender
from Mailman.configuration import config
from Mailman.i18n import _
@@ -176,7 +177,7 @@ To obtain instructions, send a message containing just the word "help".
# BAW: We wait until now to make this decision since our sender may
# not be self.msg.get_sender(), but I'm not sure this is right.
recip = self.returnaddr or self.msg.get_sender()
- if not self.mlist.autorespondToSender(recip, self.msgdata['lang']):
+ if not autorespond_to_sender(self.mlist, recip, self.msgdata['lang']):
return
msg = Message.UserNotification(
recip,
diff --git a/Mailman/Utils.py b/Mailman/Utils.py
index 9d6fdf145..0bd8fa2a6 100644
--- a/Mailman/Utils.py
+++ b/Mailman/Utils.py
@@ -887,3 +887,31 @@ def strip_verbose_pattern(pattern):
newpattern += c
i += 1
return newpattern
+
+
+
+def get_pattern(email, pattern_list):
+ """Returns matched entry in pattern_list if email matches.
+ Otherwise returns None.
+ """
+ if not pattern_list:
+ return None
+ matched = None
+ for pattern in pattern_list:
+ if pattern.startswith('^'):
+ # This is a regular expression match
+ try:
+ if re.search(pattern, email, re.IGNORECASE):
+ matched = pattern
+ break
+ except re.error:
+ # BAW: we should probably remove this pattern
+ pass
+ else:
+ # Do the comparison case insensitively
+ if pattern.lower() == email.lower():
+ matched = pattern
+ break
+ return matched
+
+
diff --git a/Mailman/app/archiving.py b/Mailman/app/archiving.py
new file mode 100644
index 000000000..6ab3479eb
--- /dev/null
+++ b/Mailman/app/archiving.py
@@ -0,0 +1,36 @@
+# 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.
+
+"""Application level archiving support."""
+
+from string import Template
+
+from Mailman.configuration import config
+
+
+
+def get_base_archive_url(mlist):
+ if mlist.archive_private:
+ url = mlist.script_url('private') + '/index.html'
+ else:
+ web_host = config.domains.get(mlist.host_name, mlist.host_name)
+ url = Template(config.PUBLIC_ARCHIVE_URL).safe_substitute(
+ listname=mlist.fqdn_listname,
+ hostname=web_host,
+ fqdn_listname=mlist.fqdn_listname,
+ )
+ return url
diff --git a/Mailman/app/bounces.py b/Mailman/app/bounces.py
new file mode 100644
index 000000000..6df5c8aa6
--- /dev/null
+++ b/Mailman/app/bounces.py
@@ -0,0 +1,163 @@
+# 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.
+
+"""Application level bounce handling."""
+
+__all__ = [
+ 'bounce_message',
+ 'has_explicit_destination',
+ 'has_matching_bounce_header',
+ ]
+
+import re
+import logging
+
+from email.mime.message import MIMEMessage
+from email.mime.text import MIMEText
+from email.utils import getaddresses
+
+from Mailman import Message
+from Mailman import Utils
+from Mailman.i18n import _
+
+log = logging.getLogger('mailman.config')
+
+
+
+def bounce_message(mlist, msg, e=None):
+ # Bounce a message back to the sender, with an error message if provided
+ # in the exception argument.
+ sender = msg.get_sender()
+ subject = msg.get('subject', _('(no subject)'))
+ subject = Utils.oneline(subject,
+ Utils.GetCharSet(mlist.preferred_language))
+ if e is None:
+ notice = _('[No bounce details are available]')
+ else:
+ notice = _(e.notice)
+ # Currently we always craft bounces as MIME messages.
+ bmsg = Message.UserNotification(msg.get_sender(),
+ mlist.owner_address,
+ subject,
+ lang=mlist.preferred_language)
+ # BAW: Be sure you set the type before trying to attach, or you'll get
+ # a MultipartConversionError.
+ bmsg.set_type('multipart/mixed')
+ txt = MIMEText(notice,
+ _charset=Utils.GetCharSet(mlist.preferred_language))
+ bmsg.attach(txt)
+ bmsg.attach(MIMEMessage(msg))
+ bmsg.send(mlist)
+
+
+
+# Helper function used to match a pattern against an address.
+def _domatch(pattern, addr):
+ try:
+ if re.match(pattern, addr, re.IGNORECASE):
+ return True
+ except re.error:
+ # The pattern is a malformed regexp -- try matching safely,
+ # with all non-alphanumerics backslashed:
+ if re.match(re.escape(pattern), addr, re.IGNORECASE):
+ return True
+ return False
+
+
+def has_explicit_destination(mlist, msg):
+ """Does the list's name or an acceptable alias appear in the recipients?
+
+ :param mlist: The mailing list the message is destined for.
+ :param msg: The email message object.
+ :return: True if the message is explicitly destined for the mailing list,
+ otherwise False.
+ """
+ # Check all recipient addresses against the list's explicit addresses,
+ # specifically To: Cc: and Resent-to:
+ recipients = []
+ to = []
+ for header in ('to', 'cc', 'resent-to', 'resent-cc'):
+ to.extend(getaddresses(msg.get_all(header, [])))
+ for fullname, address in to:
+ # It's possible that if the header doesn't have a valid RFC 2822
+ # value, we'll get None for the address. So skip it.
+ if address is None or '@' not in address:
+ continue
+ address = address.lower()
+ if address == mlist.posting_address:
+ return True
+ recipients.append(address)
+ # Match the set of recipients against the list's acceptable aliases.
+ aliases = mlist.acceptable_aliases.splitlines()
+ for address in recipients:
+ for alias in aliases:
+ stripped = alias.strip()
+ if not stripped:
+ # Ignore blank or empty lines
+ continue
+ if domatch(stripped, address):
+ return True
+ return False
+
+
+
+def _parse_matching_header_opt(mlist):
+ """Return a list of triples [(field name, regex, line), ...]."""
+ # - Blank lines and lines with '#' as first char are skipped.
+ # - Leading whitespace in the matchexp is trimmed - you can defeat
+ # that by, eg, containing it in gratuitous square brackets.
+ all = []
+ for line in mlist.bounce_matching_headers.splitlines():
+ line = line.strip()
+ # Skip blank lines and lines *starting* with a '#'.
+ if not line or line.startswith('#'):
+ continue
+ i = line.find(':')
+ if i < 0:
+ # This didn't look like a header line. BAW: should do a
+ # better job of informing the list admin.
+ log.error('bad bounce_matching_header line: %s\n%s',
+ mlist.real_name, line)
+ else:
+ header = line[:i]
+ value = line[i+1:].lstrip()
+ try:
+ cre = re.compile(value, re.IGNORECASE)
+ except re.error, e:
+ # The regexp was malformed. BAW: should do a better
+ # job of informing the list admin.
+ log.error("""\
+bad regexp in bounce_matching_header line: %s
+\n%s (cause: %s)""", mlist.real_name, value, e)
+ else:
+ all.append((header, cre, line))
+ return all
+
+
+def has_matching_bounce_header(mlist, msg):
+ """Does the message have a matching bounce header?
+
+ :param mlist: The mailing list the message is destined for.
+ :param msg: The email message object.
+ :return: True if a header field matches a regexp in the
+ bounce_matching_header mailing list variable.
+ """
+ for header, cre, line in _parse_matching_header_opt(mlist):
+ for value in msg.get_all(header, []):
+ if cre.search(value):
+ return True
+ return False
diff --git a/Mailman/app/lifecycle.py b/Mailman/app/lifecycle.py
index 1c40feaeb..8c693d39d 100644
--- a/Mailman/app/lifecycle.py
+++ b/Mailman/app/lifecycle.py
@@ -53,7 +53,7 @@ def create_list(fqdn_listname, owners=None):
# be necessary. Until then, setattr on the MailList instance won't
# set the database column values, so pass the underlying database
# object to .apply() instead.
- style.apply(mlist._data)
+ style.apply(mlist)
# Coordinate with the MTA, which should be defined by plugins.
# XXX FIXME
## mta_plugin = get_plugin('mailman.mta')
diff --git a/Mailman/app/membership.py b/Mailman/app/membership.py
index 0f12a1d29..3096ae7f4 100644
--- a/Mailman/app/membership.py
+++ b/Mailman/app/membership.py
@@ -58,7 +58,7 @@ def add_member(mlist, address, realname, password, delivery_mode, language,
raise Errors.AlreadySubscribedError(address)
# Check for banned address here too for admin mass subscribes and
# confirmations.
- pattern = mlist.GetBannedPattern(address)
+ pattern = Utils.get_pattern(address, mlist.ban_list)
if pattern:
raise Errors.MembershipIsBanned(pattern)
# Do the actual addition. First, see if there's already a user linked
diff --git a/Mailman/app/replybot.py b/Mailman/app/replybot.py
new file mode 100644
index 000000000..c46931770
--- /dev/null
+++ b/Mailman/app/replybot.py
@@ -0,0 +1,89 @@
+# 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.
+
+"""Application level auto-reply code."""
+
+# XXX This should undergo a rewrite to move this functionality off of the
+# mailing list. The reply governor should really apply site-wide per
+# recipient (I think).
+
+from __future__ import with_statement
+
+__all__ = [
+ 'autorespond_to_sender',
+ ]
+
+import logging
+import datetime
+
+from Mailman import Utils
+from Mailman import i18n
+from Mailman.configuration import config
+
+
+log = logging.getLogger('mailman.vette')
+_ = i18n._
+
+
+
+def autorespond_to_sender(mlist, sender, lang=None):
+ """Return True if Mailman should auto-respond to this sender.
+
+ This is only consulted for messages sent to the -request address, or
+ for posting hold notifications, and serves only as a safety value for
+ mail loops with email 'bots.
+ """
+ if lang is None:
+ lang = mlist.preferred_language
+ if config.MAX_AUTORESPONSES_PER_DAY == 0:
+ # Unlimited.
+ return True
+ today = datetime.date.today()
+ info = mlist.hold_and_cmd_autoresponses.get(sender)
+ if info is None or info[0] <> today:
+ # This is the first time we've seen a -request/post-hold for this
+ # sender today.
+ mlist.hold_and_cmd_autoresponses[sender] = (today, 1)
+ return True
+ date, count = info
+ if count < 0:
+ # They've already hit the limit for today, and we've already notified
+ # them of this fact, so there's nothing more to do.
+ log.info('-request/hold autoresponse discarded for: %s', sender)
+ return False
+ if count >= config.MAX_AUTORESPONSES_PER_DAY:
+ log.info('-request/hold autoresponse limit hit for: %s', sender)
+ mlist.hold_and_cmd_autoresponses[sender] = (today, -1)
+ # Send this notification message instead.
+ text = Utils.maketext(
+ 'nomoretoday.txt',
+ {'sender' : sender,
+ 'listname': mlist.fqdn_listname,
+ 'num' : count,
+ 'owneremail': mlist.owner_address,
+ },
+ lang=lang)
+ with i18n.using_language(lang):
+ msg = Message.UserNotification(
+ sender, mlist.owner_address,
+ _('Last autoresponse notification for today'),
+ text, lang=lang)
+ msg.send(mlist)
+ return False
+ mlist.hold_and_cmd_autoresponses[sender] = (today, count + 1)
+ return True
+
diff --git a/Mailman/database/listmanager.py b/Mailman/database/listmanager.py
index d5a6303e6..0f6d7a9aa 100644
--- a/Mailman/database/listmanager.py
+++ b/Mailman/database/listmanager.py
@@ -17,7 +17,6 @@
"""SQLAlchemy/Elixir based provider of IListManager."""
-import weakref
import datetime
from elixir import *
@@ -34,9 +33,6 @@ from Mailman.interfaces import IListManager, IPending
class ListManager(object):
implements(IListManager)
- def __init__(self):
- self._objectmap = weakref.WeakKeyDictionary()
-
def create(self, fqdn_listname):
listname, hostname = split_listname(fqdn_listname)
mlist = MailingList.get_by(list_name=listname,
@@ -45,30 +41,18 @@ class ListManager(object):
raise Errors.MMListAlreadyExistsError(fqdn_listname)
mlist = MailingList(fqdn_listname)
mlist.created_at = datetime.datetime.now()
- # Wrap the database model object in an application MailList object and
- # return the latter. Keep track of the wrapper so we can clean it up
- # when we're done with it.
- from Mailman.MailList import MailList
- wrapper = MailList(mlist)
- self._objectmap[mlist] = wrapper
- return wrapper
+ return mlist
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()
- mlist._data = None
+ mlist.delete()
def get(self, fqdn_listname):
listname, hostname = split_listname(fqdn_listname)
- mlist = MailingList.get_by(list_name=listname,
- host_name=hostname)
- if not mlist:
- return None
- mlist._restore()
- from Mailman.MailList import MailList
- wrapper = self._objectmap.setdefault(mlist, MailList(mlist))
- return wrapper
+ mlist = MailingList.get_by(list_name=listname, host_name=hostname)
+ if mlist is not None:
+ # XXX Fixme
+ mlist._restore()
+ return mlist
@property
def mailing_lists(self):
diff --git a/Mailman/database/model/mailinglist.py b/Mailman/database/model/mailinglist.py
index 0d12f919e..7fa9aca38 100644
--- a/Mailman/database/model/mailinglist.py
+++ b/Mailman/database/model/mailinglist.py
@@ -211,3 +211,51 @@ class MailingList(Entity):
# XXX Handle the case for when context is not None; those would be
# relative URLs.
return self.web_page_url + target + '/' + self.fqdn_listname
+
+ # IMailingListAddresses
+
+ @property
+ def posting_address(self):
+ return self.fqdn_listname
+
+ @property
+ def noreply_address(self):
+ return '%s@%s' % (config.NO_REPLY_ADDRESS, self.host_name)
+
+ @property
+ def owner_address(self):
+ return '%s-owner@%s' % (self.list_name, self.host_name)
+
+ @property
+ def request_address(self):
+ return '%s-request@%s' % (self.list_name, self.host_name)
+
+ @property
+ def bounces_address(self):
+ return '%s-bounces@%s' % (self.list_name, self.host_name)
+
+ @property
+ def join_address(self):
+ return '%s-join@%s' % (self.list_name, self.host_name)
+
+ @property
+ def leave_address(self):
+ return '%s-leave@%s' % (self.list_name, self.host_name)
+
+ @property
+ def subscribe_address(self):
+ return '%s-subscribe@%s' % (self.list_name, self.host_name)
+
+ @property
+ def unsubscribe_address(self):
+ return '%s-unsubscribe@%s' % (self.list_name, self.host_name)
+
+ def confirm_address(self, cookie):
+ template = string.Template(config.VERP_CONFIRM_FORMAT)
+ local_part = template.safe_substitute(
+ address = '%s-confirm' % self.list_name,
+ cookie = cookie)
+ return '%s@%s' % (local_part, self.host_name)
+
+ def __repr__(self):
+ return '<mailing list "%s" at %#x>' % (self.fqdn_listname, id(self))
diff --git a/Mailman/database/model/requests.py b/Mailman/database/model/requests.py
index b0aa0d8d0..ea917c2b9 100644
--- a/Mailman/database/model/requests.py
+++ b/Mailman/database/model/requests.py
@@ -49,22 +49,22 @@ class ListRequests:
@property
def count(self):
- results = _Request.select_by(mailing_list=self.mailing_list._data)
+ results = _Request.select_by(mailing_list=self.mailing_list)
return len(results)
def count_of(self, request_type):
- results = _Request.select_by(mailing_list=self.mailing_list._data,
+ results = _Request.select_by(mailing_list=self.mailing_list,
type=request_type)
return len(results)
@property
def held_requests(self):
- results = _Request.select_by(mailing_list=self.mailing_list._data)
+ results = _Request.select_by(mailing_list=self.mailing_list)
for request in results:
yield request
def of_type(self, request_type):
- results = _Request.select_by(mailing_list=self.mailing_list._data,
+ results = _Request.select_by(mailing_list=self.mailing_list,
type=request_type)
for request in results:
yield request
@@ -88,12 +88,12 @@ class ListRequests:
# flush()'s.
## result = _Request.table.insert().execute(
## key=key, type=request_type,
-## mailing_list=self.mailing_list._data,
+## mailing_list=self.mailing_list,
## data_hash=data_hash)
## row_id = result.last_inserted_ids()[0]
## return row_id
result = _Request(key=key, type=request_type,
- mailing_list=self.mailing_list._data,
+ mailing_list=self.mailing_list,
data_hash=data_hash)
# XXX We need a handle on last_inserted_ids() instead of requiring a
# flush of the database to get a valid id.
diff --git a/Mailman/docs/acknowledge.txt b/Mailman/docs/acknowledge.txt
index e9242fb45..3e2088f10 100644
--- a/Mailman/docs/acknowledge.txt
+++ b/Mailman/docs/acknowledge.txt
@@ -15,7 +15,7 @@ acknowledgment.
>>> mlist.preferred_language = 'en'
>>> # 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/'
+ >>> mlist.web_page_url = 'http://lists.example.com/'
>>> flush()
>>> # Ensure that the virgin queue is empty, since we'll be checking this
diff --git a/Mailman/docs/bounces.txt b/Mailman/docs/bounces.txt
index aa1639e5a..cfc7aa49e 100644
--- a/Mailman/docs/bounces.txt
+++ b/Mailman/docs/bounces.txt
@@ -37,7 +37,8 @@ to the original messageauthor.
>>> from Mailman.Queue.Switchboard import Switchboard
>>> switchboard = Switchboard(config.VIRGINQUEUE_DIR)
- >>> mlist.bounce_message(msg)
+ >>> from Mailman.app.bounces import bounce_message
+ >>> bounce_message(mlist, msg)
>>> len(switchboard.files)
1
>>> filebase = switchboard.files[0]
@@ -77,7 +78,7 @@ passed in as an instance of a RejectMessage exception.
>>> from Mailman.Errors import RejectMessage
>>> error = RejectMessage("This wasn't very important after all.")
- >>> mlist.bounce_message(msg, error)
+ >>> bounce_message(mlist, msg, error)
>>> len(switchboard.files)
1
>>> filebase = switchboard.files[0]
diff --git a/Mailman/docs/cook-headers.txt b/Mailman/docs/cook-headers.txt
index 4f8885e79..ffe2dfa5f 100644
--- a/Mailman/docs/cook-headers.txt
+++ b/Mailman/docs/cook-headers.txt
@@ -18,7 +18,7 @@ is getting sent through the system. We'll take things one-by-one.
>>> 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/'
+ >>> mlist.web_page_url = 'http://lists.example.com/'
>>> flush()
diff --git a/Mailman/docs/hold.txt b/Mailman/docs/hold.txt
index 62b621bdc..3aeb56934 100644
--- a/Mailman/docs/hold.txt
+++ b/Mailman/docs/hold.txt
@@ -16,7 +16,7 @@ are held when they meet any of a number of criteria.
>>> 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/'
+ >>> mlist.web_page_url = 'http://lists.example.com/'
>>> flush()
Here's a helper function used when we don't care about what's in the virgin
diff --git a/Mailman/docs/listmanager.txt b/Mailman/docs/listmanager.txt
index 1f4586e35..030f2ecd5 100644
--- a/Mailman/docs/listmanager.txt
+++ b/Mailman/docs/listmanager.txt
@@ -61,14 +61,6 @@ Use the list manager to delete a mailing list.
>>> sorted(listmgr.names)
[]
-Attempting to access attributes of the deleted mailing list raises an
-exception:
-
- >>> mlist.fqdn_listname
- Traceback (most recent call last):
- ...
- AttributeError: fqdn_listname
-
After deleting the list, you can create it again.
>>> mlist = listmgr.create('_xtest@example.com')
diff --git a/Mailman/docs/replybot.txt b/Mailman/docs/replybot.txt
index 2db4e4b07..0b1980fde 100644
--- a/Mailman/docs/replybot.txt
+++ b/Mailman/docs/replybot.txt
@@ -13,6 +13,7 @@ message or the amount of time since the last auto-response.
>>> from Mailman.database import flush
>>> mlist = config.db.list_manager.create('_xtest@example.com')
>>> mlist.real_name = 'XTest'
+ >>> mlist.web_page_url = 'http://www.example.com/'
>>> flush()
>>> # Ensure that the virgin queue is empty, since we'll be checking this
@@ -32,8 +33,9 @@ is sent to one of these addresses. A mailing list also has an autoresponse
grace period which describes how much time must pass before a second response
will be sent, with 0 meaning "there is no grace period".
+ >>> import datetime
>>> mlist.autorespond_admin = True
- >>> mlist.autoresponse_graceperiod = 0
+ >>> mlist.autoresponse_graceperiod = datetime.timedelta()
>>> mlist.autoresponse_admin_text = 'admin autoresponse text'
>>> flush()
>>> msg = message_from_string("""\
diff --git a/Mailman/docs/requests.txt b/Mailman/docs/requests.txt
index e513b7cea..249cab952 100644
--- a/Mailman/docs/requests.txt
+++ b/Mailman/docs/requests.txt
@@ -451,7 +451,7 @@ queue when the message is held.
>>> mlist.admin_immed_notify = True
>>> # XXX This will almost certainly change once we've worked out the web
>>> # space layout for mailing lists now.
- >>> mlist._data.web_page_url = 'http://www.example.com/'
+ >>> mlist.web_page_url = 'http://www.example.com/'
>>> flush()
>>> id_4 = moderator.hold_subscription(mlist,
... 'cperson@example.org', 'Claire Person',
diff --git a/Mailman/docs/scrubber.txt b/Mailman/docs/scrubber.txt
index 344894666..0c8c4d94f 100644
--- a/Mailman/docs/scrubber.txt
+++ b/Mailman/docs/scrubber.txt
@@ -19,7 +19,8 @@ Helper functions for getting the attachment data.
>>> import os, re
>>> def read_attachment(filename, remove=True):
- ... path = os.path.join(mlist.archive_dir(), filename)
+ ... path = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR,
+ ... mlist.fqdn_listname, filename)
... fp = open(path)
... try:
... data = fp.read()
diff --git a/TODO.txt b/TODO.txt
index e4870f511..edf30aec0 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -4,7 +4,7 @@ things that I need to do.
Fix the XXX in model/requests.py where we need a flush because we can't get to
last_inserted_id()
Get rid of PickleTypes
-Get rid of MailList class!
+Get rid of MailList class! (done for test suite!)
Add tests for bin/newlist and bin/rmlist
Add tests for plugins
Rework MTA plugins and add tests