aboutsummaryrefslogtreecommitdiffstats
path: root/Mailman/Handlers
diff options
context:
space:
mode:
Diffstat (limited to 'Mailman/Handlers')
-rw-r--r--Mailman/Handlers/.cvsignore1
-rw-r--r--Mailman/Handlers/Acknowledge.py62
-rw-r--r--Mailman/Handlers/AfterDelivery.py28
-rw-r--r--Mailman/Handlers/Approve.py82
-rw-r--r--Mailman/Handlers/AvoidDuplicates.py88
-rw-r--r--Mailman/Handlers/CalcRecips.py133
-rw-r--r--Mailman/Handlers/Cleanse.py39
-rw-r--r--Mailman/Handlers/CookHeaders.py254
-rw-r--r--Mailman/Handlers/Decorate.py183
-rw-r--r--Mailman/Handlers/Emergency.py37
-rw-r--r--Mailman/Handlers/FileRecips.py49
-rw-r--r--Mailman/Handlers/Hold.py280
-rw-r--r--Mailman/Handlers/Makefile.in69
-rw-r--r--Mailman/Handlers/MimeDel.py220
-rw-r--r--Mailman/Handlers/Moderate.py164
-rw-r--r--Mailman/Handlers/OwnerRecips.py27
-rw-r--r--Mailman/Handlers/Replybot.py120
-rw-r--r--Mailman/Handlers/SMTPDirect.py349
-rw-r--r--Mailman/Handlers/Scrubber.py400
-rw-r--r--Mailman/Handlers/Sendmail.py116
-rw-r--r--Mailman/Handlers/SpamDetect.py50
-rw-r--r--Mailman/Handlers/Tagger.py156
-rw-r--r--Mailman/Handlers/ToArchive.py39
-rw-r--r--Mailman/Handlers/ToDigest.py351
-rw-r--r--Mailman/Handlers/ToOutgoing.py55
-rw-r--r--Mailman/Handlers/ToUsenet.py44
-rw-r--r--Mailman/Handlers/__init__.py15
27 files changed, 3411 insertions, 0 deletions
diff --git a/Mailman/Handlers/.cvsignore b/Mailman/Handlers/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Handlers/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Handlers/Acknowledge.py b/Mailman/Handlers/Acknowledge.py
new file mode 100644
index 00000000..103448e1
--- /dev/null
+++ b/Mailman/Handlers/Acknowledge.py
@@ -0,0 +1,62 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Send an acknowledgement of the successful post to the sender.
+
+This only happens if the sender has set their AcknowledgePosts attribute.
+This module must appear after the deliverer in the message pipeline in order
+to send acks only after successful delivery.
+
+"""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import Errors
+from Mailman.i18n import _
+
+
+
+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, mm_cfg.AcknowledgePosts)
+ if not ack:
+ return
+ except Errors.NotAMemberError:
+ 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))
+ # Now get the acknowledgement template
+ realname = mlist.real_name
+ text = Utils.maketext(
+ 'postack.txt',
+ {'subject' : origsubj,
+ 'listname' : realname,
+ 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=1),
+ 'optionsurl' : mlist.GetOptionsURL(sender, absolute=1),
+ }, lang=lang, mlist=mlist, raw=1)
+ # Craft the outgoing message, with all headers and attributes
+ # necessary for general delivery. Then enqueue it to the outgoing
+ # queue.
+ subject = _('%(realname)s post acknowledgement')
+ usermsg = Message.UserNotification(sender, mlist.GetBouncesEmail(),
+ subject, text, lang)
+ usermsg.send(mlist)
diff --git a/Mailman/Handlers/AfterDelivery.py b/Mailman/Handlers/AfterDelivery.py
new file mode 100644
index 00000000..b6bb96c2
--- /dev/null
+++ b/Mailman/Handlers/AfterDelivery.py
@@ -0,0 +1,28 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Perform some bookkeeping after a successful post.
+
+This module must appear after the delivery module in the message pipeline.
+"""
+
+import time
+
+
+
+def process(mlist, msg, msgdata):
+ mlist.last_post_time = time.time()
+ mlist.post_id += 1
diff --git a/Mailman/Handlers/Approve.py b/Mailman/Handlers/Approve.py
new file mode 100644
index 00000000..d339a9b1
--- /dev/null
+++ b/Mailman/Handlers/Approve.py
@@ -0,0 +1,82 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Determine whether the message is approved for delivery.
+
+This module only tests for definitive approvals. IOW, this module only
+determines whether the message is definitively approved or definitively
+denied. Situations that could hold a message for approval or confirmation are
+not tested by this module.
+
+"""
+
+from email.Iterators import typed_subpart_iterator
+
+from Mailman import mm_cfg
+from Mailman import Errors
+
+NL = '\n'
+
+
+
+def process(mlist, msg, msgdata):
+ # Short circuits
+ if msgdata.get('approved'):
+ # Digests, Usenet postings, and some other messages come pre-approved.
+ # TBD: we may want to further filter Usenet messages, so the test
+ # above may not be entirely correct.
+ return
+ # See if the message has an Approved or Approve header with a valid
+ # list-moderator, list-admin. Also look at the first non-whitespace line
+ # in the file to see if it looks like an Approved header. We are
+ # specifically /not/ allowing the site admins password to work here
+ # because we want to discourage the practice of sending the site admin
+ # password through email in the clear.
+ missing = []
+ passwd = msg.get('approved', msg.get('approve', missing))
+ if passwd is missing:
+ # Find the first text/plain part in the message
+ part = None
+ for part in typed_subpart_iterator(msg, 'text', 'plain'):
+ break
+ if part is not None:
+ lines = part.get_payload().splitlines()
+ line = ''
+ for lineno, line in zip(range(len(lines)), lines):
+ if line.strip():
+ break
+ i = line.find(':')
+ if i >= 0:
+ name = line[:i]
+ value = line[i+1:]
+ if name.lower() in ('approve', 'approved'):
+ passwd = value.lstrip()
+ # Now strip the first line from the payload so the
+ # password doesn't leak.
+ del lines[lineno]
+ part.set_payload(NL.join(lines[1:]))
+ if passwd is not missing and mlist.Authenticate((mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin),
+ passwd):
+ # BAW: should we definitely deny if the password exists but does not
+ # match? For now we'll let it percolate up for further determination.
+ msgdata['approved'] = 1
+ # Used by the Emergency module
+ msgdata['adminapproved'] = 1
+ # has this message already been posted to this list?
+ beentheres = [s.strip().lower() for s in msg.get_all('x-beenthere', [])]
+ if mlist.GetListEmail().lower() in beentheres:
+ raise Errors.LoopError
diff --git a/Mailman/Handlers/AvoidDuplicates.py b/Mailman/Handlers/AvoidDuplicates.py
new file mode 100644
index 00000000..af740da2
--- /dev/null
+++ b/Mailman/Handlers/AvoidDuplicates.py
@@ -0,0 +1,88 @@
+# Copyright (C) 2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""If the user wishes it, do not send duplicates of the same message.
+
+This module keeps an in-memory dictionary of Message-ID: and recipient pairs.
+If a message with an identical Message-ID: is about to be sent to someone who
+has already received a copy, we either drop the message, add a duplicate
+warning header, or pass it through, depending on the user's preferences.
+"""
+
+from Mailman import mm_cfg
+
+from email.Utils import getaddresses, formataddr
+
+
+
+def process(mlist, msg, msgdata):
+ recips = msgdata['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] = 1
+ # Figure out the set of explicit recipients
+ ccaddrs = {}
+ for header in ('to', 'cc', 'resent-to', 'resent-cc'):
+ addrs = getaddresses(msg.get_all(header, []))
+ 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] = 1
+ # Now strip out the list addresses
+ for addr in listaddrs:
+ del explicit_recips[addr]
+ if not explicit_recips:
+ # No one was explicitly addressed, so we can't do any dup collapsing
+ return
+ newrecips = []
+ for r in recips:
+ # If this recipient is explicitly addressed...
+ if explicit_recips.has_key(r):
+ send_duplicate = 1
+ # If the member wants to receive duplicates, or if the recipient
+ # is not a member at all, just flag the X-Mailman-Duplicate: yes
+ # header.
+ if mlist.isMember(r) and \
+ mlist.getMemberOption(r, mm_cfg.DontReceiveDuplicates):
+ send_duplicate = 0
+ # 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] = 1
+ newrecips.append(r)
+ elif ccaddrs.has_key(r):
+ del ccaddrs[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
+ del msg['cc']
+ for item in ccaddrs.values():
+ msg['cc'] = formataddr(item)
diff --git a/Mailman/Handlers/CalcRecips.py b/Mailman/Handlers/CalcRecips.py
new file mode 100644
index 00000000..1b2a600b
--- /dev/null
+++ b/Mailman/Handlers/CalcRecips.py
@@ -0,0 +1,133 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Calculate the regular (i.e. non-digest) recipients of the message.
+
+This module calculates the non-digest recipients for the message based on the
+list's membership and configuration options. It places the list of recipients
+on the `recips' attribute of the message. This attribute is used by the
+SendmailDeliver and BulkDeliver modules.
+"""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import Errors
+from Mailman.MemberAdaptor import ENABLED
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+
+
+
+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'):
+ return
+ # Should the original sender should be included in the recipients list?
+ include_sender = 1
+ sender = msg.get_sender()
+ try:
+ if mlist.getMemberOption(sender, mm_cfg.DontReceiveOwnPosts):
+ include_sender = 0
+ except Errors.NotAMemberError:
+ pass
+ # 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
+ # because we want to discourage the practice of sending the site admin
+ # password through email in the clear. (see also Approve.py)
+ missing = []
+ password = msg.get('urgent', missing)
+ if password is not missing:
+ if mlist.Authenticate((mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin),
+ password):
+ recips = mlist.getMemberCPAddresses(mlist.getRegularMemberKeys() +
+ mlist.getDigestMemberKeys())
+ msgdata['recips'] = recips
+ return
+ else:
+ # Bad Urgent: password, so reject it instead of passing it on. I
+ # think it's better that the sender know they screwed up than to
+ # deliver it normally.
+ realname = mlist.real_name
+ text = _("""\
+Your urgent message to the %(realname)s mailing list was not authorized for
+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]
+ # 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
+ # Handle topic classifications
+ do_topic_filters(mlist, msg, msgdata, recips)
+ # Bookkeeping
+ msgdata['recips'] = recips
+
+
+
+def do_topic_filters(mlist, msg, msgdata, recips):
+ hits = msgdata.get('topichits')
+ zaprecips = []
+ if hits:
+ # The message hit some topics, so only deliver this message to those
+ # who are interested in one of the hit topics.
+ for user in recips:
+ utopics = mlist.getMemberTopics(user)
+ if not utopics:
+ # This user is not interested in any topics, so they get all
+ # postings.
+ continue
+ # BAW: Slow, first-match, set intersection!
+ for topic in utopics:
+ if topic in hits:
+ # The user wants this message
+ break
+ else:
+ # The user was interested in topics, but not any of the ones
+ # this message matched, so zap him.
+ zaprecips.append(user)
+ else:
+ # The semantics for a message that did not hit any of the pre-canned
+ # topics is to troll through the membership list, looking for users
+ # who selected at least one topic of interest, but turned on
+ # ReceiveNonmatchingTopics.
+ for user in recips:
+ if not mlist.getMemberTopics(user):
+ # The user did not select any topics of interest, so he gets
+ # this message by default.
+ continue
+ if not mlist.getMemberOption(user,
+ mm_cfg.ReceiveNonmatchingTopics):
+ # The user has interest in some topics, but elects not to
+ # receive message that match no topics, so zap him.
+ zaprecips.append(user)
+ # Otherwise, the user wants non-matching messages.
+ # Prune out the non-receiving users
+ for user in zaprecips:
+ recips.remove(user)
+
diff --git a/Mailman/Handlers/Cleanse.py b/Mailman/Handlers/Cleanse.py
new file mode 100644
index 00000000..143f9500
--- /dev/null
+++ b/Mailman/Handlers/Cleanse.py
@@ -0,0 +1,39 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Cleanse certain headers from all messages."""
+
+
+def process(mlist, msg, msgdata):
+ # Always remove this header from any outgoing messages. Be sure to do
+ # this after the information on the header is actually used, but before a
+ # permanent record of the header is saved.
+ del msg['approved']
+ # Also remove this header since it can contain a password
+ del msg['urgent']
+ # We remove other headers from anonymous lists
+ if mlist.anonymous_list:
+ del msg['from']
+ del msg['reply-to']
+ del msg['sender']
+ msg['From'] = mlist.GetListEmail()
+ msg['Reply-To'] = mlist.GetListEmail()
+ # Some headers can be used to fish for membership
+ del msg['return-receipt-to']
+ del msg['disposition-notification-to']
+ del msg['x-confirm-reading-to']
+ # Pegasus mail uses this one... sigh
+ del msg['x-pmrqc']
diff --git a/Mailman/Handlers/CookHeaders.py b/Mailman/Handlers/CookHeaders.py
new file mode 100644
index 00000000..40eddd66
--- /dev/null
+++ b/Mailman/Handlers/CookHeaders.py
@@ -0,0 +1,254 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Cook a message's Subject header.
+"""
+
+from __future__ import nested_scopes
+import re
+from types import UnicodeType
+
+from email.Charset import Charset
+from email.Header import Header, decode_header
+from email.Utils import parseaddr, formataddr, getaddresses
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+
+CONTINUATION = ',\n\t'
+COMMASPACE = ', '
+MAXLINELEN = 78
+
+
+
+def _isunicode(s):
+ return isinstance(s, UnicodeType)
+
+def uheader(mlist, s, header_name=None):
+ # Get the charset to encode the string in. If this is us-ascii, we'll use
+ # iso-8859-1 instead, just to get a little extra coverage, and because the
+ # Header class tries us-ascii first anyway.
+ charset = Utils.GetCharSet(mlist.preferred_language)
+ if charset == 'us-ascii':
+ charset = 'iso-8859-1'
+ charset = Charset(charset)
+ # Convert the string to unicode so Header will do the 3-charset encoding.
+ # If s is a byte string and there are funky characters in it that don't
+ # match the charset, we might as well replace them now.
+ if not _isunicode(s):
+ codec = charset.input_codec or 'ascii'
+ s = unicode(s, codec, 'replace')
+ # We purposefully leave no space b/w prefix and subject!
+ return Header(s, charset, header_name=header_name)
+
+
+
+def process(mlist, msg, msgdata):
+ # Set the "X-Ack: no" header if noack flag is set.
+ if msgdata.get('noack'):
+ del msg['x-ack']
+ msg['X-Ack'] = 'no'
+ # Because we're going to modify various important headers in the email
+ # message, we want to save some of the information in the msgdata
+ # dictionary for later. Specifically, the sender header will get waxed,
+ # but we need it for the Acknowledge module later.
+ msgdata['original_sender'] = msg.get_sender()
+ # VirginRunner sets _fasttrack for internally crafted messages.
+ fasttrack = msgdata.get('_fasttrack')
+ if not msgdata.get('isdigest') and not fasttrack:
+ prefix_subject(mlist, msg, msgdata)
+ # Mark message so we know we've been here, but leave any existing
+ # X-BeenThere's intact.
+ msg['X-BeenThere'] = mlist.GetListEmail()
+ # 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.
+ # Good places to look are:
+ #
+ # http://www.dsv.su.se/~jpalme/ietf/jp-ietf-home.html
+ # http://www.faqs.org/rfcs/rfc2076.html
+ #
+ # None of these headers are added if they already exist. BAW: some
+ # consider the advertising of this a security breach. I.e. if there are
+ # 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'):
+ msg['X-Mailman-Version'] = mm_cfg.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'):
+ 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:
+ # Yuck, I really hate this feature but I've caved under the sheer pressure
+ # of the (very vocal) folks want it. OTOH, RFC 2822 allows Reply-To: to
+ # be a list of addresses, so instead of replacing the original, simply
+ # augment it. RFC 2822 allows max one Reply-To: header so collapse them
+ # if we're adding a value, otherwise don't touch it. (Should we collapse
+ # in all cases?)
+ if not fasttrack:
+ # A convenience function, requires nested scopes. pair is (name, addr)
+ new = []
+ d = {}
+ def add(pair):
+ lcaddr = pair[1].lower()
+ if d.has_key(lcaddr):
+ return
+ d[lcaddr] = pair
+ new.append(pair)
+ # List admin wants an explicit Reply-To: added
+ if mlist.reply_goes_to_list == 2:
+ 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
+ # cases we'll zap the existing field because RFC 2822 says max one is
+ # allowed.
+ if not mlist.first_strip_reply_to:
+ orig = msg.get_all('reply-to', [])
+ for pair in getaddresses(orig):
+ add(pair)
+ # 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:
+ i18ndesc = uheader(mlist, mlist.description)
+ add((str(i18ndesc), mlist.GetListEmail()))
+ del msg['reply-to']
+ # Don't put Reply-To: back if there's nothing to add!
+ if new:
+ # Preserve order
+ msg['Reply-To'] = COMMASPACE.join(
+ [formataddr(pair) for pair in new])
+ # The To field normally contains the list posting address. However
+ # when messages are fully personalized, that header will get
+ # overwritten with the address of the recipient. We need to get the
+ # posting address in one of the recipient headers or they won't be
+ # able to reply back to the list. It's possible the posting address
+ # was munged into the Reply-To header, but if not, we'll add it to a
+ # Cc header. BAW: should we force it into a Reply-To header in the
+ # above code?
+ if mlist.personalize == 2 and mlist.reply_goes_to_list <> 1:
+ # Watch out for existing Cc headers, merge, and remove dups. Note
+ # that RFC 2822 says only zero or one Cc header is allowed.
+ new = []
+ d = {}
+ for pair in getaddresses(msg.get_all('cc', [])):
+ add(pair)
+ i18ndesc = uheader(mlist, mlist.description)
+ add((str(i18ndesc), mlist.GetListEmail()))
+ del msg['Cc']
+ msg['Cc'] = COMMASPACE.join([formataddr(pair) for pair in new])
+ # Add list-specific headers as defined in RFC 2369 and RFC 2919, but only
+ # if the message is being crafted for a specific list (e.g. not for the
+ # password reminders).
+ #
+ # BAW: Some people really hate the List-* headers. It seems that the free
+ # version of Eudora (possibly on for some platforms) does not hide these
+ # headers by default, pissing off their users. Too bad. Fix the MUAs.
+ if msgdata.get('_nolist') or not mlist.include_rfc2369_headers:
+ return
+ # Pre-calculate
+ listid = '<%s.%s>' % (mlist.internal_name(), mlist.host_name)
+ if mlist.description:
+ # Make sure description is properly i18n'd
+ listid_h = uheader(mlist, mlist.description, 'List-Id')
+ listid_h.append(' ' + listid, 'us-ascii')
+ else:
+ # For wrapping
+ listid_h = Header(listid, 'us-ascii', header_name='List-Id')
+ # We always add a List-ID: header.
+ del msg['list-id']
+ msg['List-Id'] = listid_h
+ # 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 adda a bunch of other RFC
+ # 2369 headers.
+ requestaddr = mlist.GetRequestEmail()
+ subfieldfmt = '<%s>, <mailto:%s?subject=%ssubscribe>'
+ listinfo = mlist.GetScriptURL('listinfo', absolute=1)
+ headers = {}
+ if msgdata.get('reduced_list_headers'):
+ headers['X-List-Administrivia'] = 'yes'
+ else:
+ headers.update({
+ 'List-Help' : '<mailto:%s?subject=help>' % requestaddr,
+ 'List-Unsubscribe': subfieldfmt % (listinfo, requestaddr, 'un'),
+ 'List-Subscribe' : subfieldfmt % (listinfo, requestaddr, ''),
+ })
+ # List-Post: is controlled by a separate attribute
+ if mlist.include_list_post_header:
+ headers['List-Post'] = '<mailto:%s>' % mlist.GetListEmail()
+ # Add this header if we're archiving
+ if mlist.archive:
+ archiveurl = mlist.GetBaseArchiveURL()
+ if archiveurl.endswith('/'):
+ archiveurl = archiveurl[:-1]
+ headers['List-Archive'] = '<%s>' % archiveurl
+ # First we delete any pre-existing headers because the RFC permits only
+ # one copy of each, and we want to be sure it's ours.
+ for h, v in headers.items():
+ del msg[h]
+ # Wrap these lines if they are too long. 78 character width probably
+ # shouldn't be hardcoded, but is at least text-MUA friendly. The
+ # adding of 2 is for the colon-space separator.
+ if len(h) + 2 + len(v) > 78:
+ v = CONTINUATION.join(v.split(', '))
+ msg[h] = v
+
+
+
+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
+ subject = msg['subject']
+ msgdata['origsubj'] = subject
+ # The header may be multilingual; decode it from base64/quopri and search
+ # each chunk for the prefix. BAW: Note that if the prefix contains spaces
+ # and each word of the prefix is encoded in a different chunk in the
+ # header, we won't find it. I think in practice that's unlikely though.
+ headerbits = decode_header(subject)
+ if prefix and subject:
+ pattern = re.escape(prefix.strip())
+ for decodedsubj, charset in headerbits:
+ if re.search(pattern, decodedsubj, re.IGNORECASE):
+ # The subject's already got the prefix, so don't change it
+ return
+ del msg['subject']
+ if not subject:
+ subject = _('(no subject)')
+ # Get the header as a Header instance, with proper unicode conversion
+ h = uheader(mlist, prefix, 'Subject')
+ for s, c in headerbits:
+ # Once again, convert the string to unicode.
+ if c is None:
+ c = Charset('iso-8859-1')
+ if not isinstance(c, Charset):
+ c = Charset(c)
+ if not _isunicode(s):
+ codec = c.input_codec or 'ascii'
+ try:
+ s = unicode(s, codec, 'replace')
+ except LookupError:
+ # Unknown codec, is this default reasonable?
+ s = unicode(s, Utils.GetCharSet(mlist.preferred_language),
+ 'replace')
+ h.append(s, c)
+ msg['Subject'] = h
diff --git a/Mailman/Handlers/Decorate.py b/Mailman/Handlers/Decorate.py
new file mode 100644
index 00000000..5605e321
--- /dev/null
+++ b/Mailman/Handlers/Decorate.py
@@ -0,0 +1,183 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Decorate a message by sticking the header and footer around it.
+"""
+
+from types import ListType
+from email.MIMEText import MIMEText
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.Message import Message
+from Mailman.i18n import _
+from Mailman.SafeDict import SafeDict
+from Mailman.Logging.Syslog import syslog
+
+
+
+def process(mlist, msg, msgdata):
+ # Digests and Mailman-craft messages should not get additional headers
+ if msgdata.get('isdigest') or msgdata.get('nodecorate'):
+ return
+ d = {}
+ if msgdata.get('personalize'):
+ # Calculate the extra personalization dictionary. Note that the
+ # length of the recips list better be exactly 1.
+ recips = msgdata.get('recips')
+ assert type(recips) == ListType and len(recips) == 1
+ member = recips[0].lower()
+ d['user_address'] = member
+ try:
+ d['user_delivered_to'] = mlist.getMemberCPAddress(member)
+ # BAW: Hmm, should we allow this?
+ d['user_password'] = mlist.getMemberPassword(member)
+ d['user_language'] = mlist.getMemberLanguage(member)
+ d['user_name'] = mlist.getMemberName(member) or _('not available')
+ d['user_optionsurl'] = mlist.GetOptionsURL(member)
+ except Errors.NotAMemberError:
+ pass
+ # These strings are descriptive for the log file and shouldn't be i18n'd
+ header = decorate(mlist, mlist.msg_header, 'non-digest header', d)
+ footer = decorate(mlist, mlist.msg_footer, 'non-digest footer', d)
+ # Escape hatch if both the footer and header are empty
+ if not header and not footer:
+ return
+ # Be MIME smart here. We only attach the header and footer by
+ # concatenation when the message is a non-multipart of type text/plain.
+ # Otherwise, if it is not a multipart, we make it a multipart, and then we
+ # add the header and footer as text/plain parts.
+ #
+ # BJG: In addition, only add the footer if the message's character set
+ # matches the charset of the list's preferred language. This is a
+ # suboptimal solution, and should be solved by allowing a list to have
+ # multiple headers/footers, for each language the list supports.
+ #
+ # Also, if the list's preferred charset is us-ascii, we can always
+ # safely add the header/footer to a plain text message since all
+ # charsets Mailman supports are strict supersets of us-ascii --
+ # no, UTF-16 emails are not supported yet.
+ mcset = msg.get_param('charset', 'us-ascii').lower()
+ lcset = Utils.GetCharSet(mlist.preferred_language)
+ msgtype = msg.get_type('text/plain')
+ # BAW: If the charsets don't match, should we add the header and footer by
+ # MIME multipart chroming the message?
+ wrap = 1
+ if not msg.is_multipart() and msgtype == 'text/plain' and \
+ msg.get('content-transfer-encoding', '').lower() <> 'base64' and \
+ (lcset == 'us-ascii' or mcset == lcset):
+ oldpayload = msg.get_payload()
+ frontsep = endsep = ''
+ if header and not header.endswith('\n'):
+ frontsep = '\n'
+ if footer and not oldpayload.endswith('\n'):
+ endsep = '\n'
+ payload = header + frontsep + oldpayload + endsep + footer
+ msg.set_payload(payload)
+ wrap = 0
+ elif msg.get_type() == 'multipart/mixed':
+ # The next easiest thing to do is just prepend the header and append
+ # the footer as additional subparts
+ mimehdr = MIMEText(header, 'plain', lcset)
+ mimeftr = MIMEText(footer, 'plain', lcset)
+ payload = msg.get_payload()
+ if not isinstance(payload, ListType):
+ payload = [payload]
+ if footer:
+ payload.append(mimeftr)
+ if header:
+ payload.insert(0, mimehdr)
+ msg.set_payload(payload)
+ wrap = 0
+ # If we couldn't add the header or footer in a less intrusive way, we can
+ # at least do it by MIME encapsulation. We want to keep as much of the
+ # outer chrome as possible.
+ if not wrap:
+ return
+ # Because of the way Message objects are passed around to process(), we
+ # need to play tricks with the outer message -- i.e. the outer one must
+ # remain the same instance. So we're going to create a clone of the outer
+ # message, with all the header chrome intact, then copy the payload to it.
+ # This will give us a clone of the original message, and it will form the
+ # basis of the interior, wrapped Message.
+ inner = Message()
+ # Which headers to copy? Let's just do the Content-* headers
+ for h, v in msg.items():
+ if h.lower().startswith('content-'):
+ inner[h] = v
+ inner.set_payload(msg.get_payload())
+ # For completeness
+ inner.set_unixfrom(msg.get_unixfrom())
+ inner.preamble = msg.preamble
+ inner.epilogue = msg.epilogue
+ # Don't copy get_charset, as this might be None, even if
+ # get_content_charset isn't. However, do make sure there is a default
+ # content-type, even if the original message was not MIME.
+ inner.set_default_type(msg.get_default_type())
+ # BAW: HACK ALERT.
+ if hasattr(msg, '__version__'):
+ inner.__version__ = msg.__version__
+ # Now, play games with the outer message to make it contain three
+ # subparts: the header (if any), the wrapped message, and the footer (if
+ # any).
+ payload = [inner]
+ if header:
+ mimehdr = MIMEText(header, 'plain', lcset)
+ payload.insert(0, mimehdr)
+ if footer:
+ mimeftr = MIMEText(footer, 'plain', lcset)
+ payload.append(mimeftr)
+ msg.set_payload(payload)
+ del msg['content-type']
+ del msg['content-transfer-encoding']
+ del msg['content-disposition']
+ msg['Content-Type'] = 'multipart/mixed'
+
+
+
+def decorate(mlist, template, what, extradict={}):
+ # `what' is just a descriptive phrase used in the log message
+ #
+ # BAW: We've found too many situations where Python can be fooled into
+ # interpolating too much revealing data into a format string. For
+ # example, a footer of "% silly %(real_name)s" would give a header
+ # containing all list attributes. While we've previously removed such
+ # really bad ones like `password' and `passwords', it's much better to
+ # provide a whitelist of known good attributes, then to try to remove a
+ # blacklist of known bad ones.
+ d = SafeDict({'real_name' : mlist.real_name,
+ 'list_name' : mlist.internal_name(),
+ # For backwards compatibility
+ '_internal_name': mlist.internal_name(),
+ 'host_name' : mlist.host_name,
+ 'web_page_url' : mlist.web_page_url,
+ 'description' : mlist.description,
+ 'info' : mlist.info,
+ 'cgiext' : mm_cfg.CGIEXT,
+ })
+ d.update(extradict)
+ # Using $-strings?
+ if getattr(mlist, 'use_dollar_strings', 0):
+ template = Utils.to_percent(template)
+ # Interpolate into the template
+ try:
+ text = (template % d).replace('\r\n', '\n')
+ except (ValueError, TypeError), e:
+ syslog('error', 'Exception while calculating %s:\n%s', what, e)
+ what = what.upper()
+ text = template
+ return text
diff --git a/Mailman/Handlers/Emergency.py b/Mailman/Handlers/Emergency.py
new file mode 100644
index 00000000..1833c9f7
--- /dev/null
+++ b/Mailman/Handlers/Emergency.py
@@ -0,0 +1,37 @@
+# Copyright (C) 2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Put an emergency hold on all messages otherwise approved.
+
+No notices are sent to either the sender or the list owner for emergency
+holds. I think they'd be too obnoxious.
+"""
+
+from Mailman import Errors
+from Mailman.i18n import _
+
+
+
+class EmergencyHold(Errors.HoldMessage):
+ reason = _('Emergency hold on all list traffic is in effect')
+ rejection = _('Your message was deemed inappropriate by the moderator.')
+
+
+
+def process(mlist, msg, msgdata):
+ if mlist.emergency and not msgdata.get('adminapproved'):
+ mlist.HoldMessage(msg, _(EmergencyHold.reason), msgdata)
+ raise EmergencyHold
diff --git a/Mailman/Handlers/FileRecips.py b/Mailman/Handlers/FileRecips.py
new file mode 100644
index 00000000..4ca4582e
--- /dev/null
+++ b/Mailman/Handlers/FileRecips.py
@@ -0,0 +1,49 @@
+# Copyright (C) 2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Get the normal delivery recipients from a Sendmail style :include: file.
+"""
+
+import os
+import errno
+
+from Mailman import Errors
+
+
+
+def process(mlist, msg, msgdata):
+ if msgdata.has_key('recips'):
+ return
+ filename = os.path.join(mlist.fullpath(), 'members.txt')
+ try:
+ fp = open(filename)
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ # If the file didn't exist, just set an empty recipients list
+ msgdata['recips'] = []
+ return
+ # Read all the lines out of the file, and strip them of the trailing nl
+ addrs = [line.strip() for line in fp.readlines()]
+ # If the sender is in that list, remove him
+ sender = msg.get_sender()
+ if mlist.isMember(sender):
+ try:
+ addrs.remove(mlist.getMemberCPAddress(sender))
+ except ValueError:
+ # Don't worry if the sender isn't in the list
+ pass
+ msgdata['recips'] = addrs
diff --git a/Mailman/Handlers/Hold.py b/Mailman/Handlers/Hold.py
new file mode 100644
index 00000000..15223959
--- /dev/null
+++ b/Mailman/Handlers/Hold.py
@@ -0,0 +1,280 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Determine whether this message should be held for approval.
+
+This modules tests only for hold situations, such as messages that are too
+large, messages that have potential administrivia, etc. Definitive approvals
+or denials are handled by a different module.
+
+If no determination can be made (i.e. none of the hold criteria matches), then
+we do nothing. If the message must be held for approval, then the hold
+database is updated and any administrator notification messages are sent.
+Finally an exception is raised to let the pipeline machinery know that further
+message handling should stop.
+
+"""
+
+import email
+from email.MIMEText import MIMEText
+from email.MIMEMessage import MIMEMessage
+import email.Utils
+from types import ClassType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman import Message
+from Mailman import i18n
+from Mailman import Pending
+from Mailman.Logging.Syslog import syslog
+
+# First, play footsie with _ so that the following are marked as translated,
+# but aren't actually translated until we need the text later on.
+def _(s):
+ return s
+
+
+
+class ForbiddenPoster(Errors.HoldMessage):
+ reason = _('Sender is explicitly forbidden')
+ rejection = _('You are forbidden from posting messages to this list.')
+
+class ModeratedPost(Errors.HoldMessage):
+ reason = _('Post to moderated list')
+ rejection = _('Your message was deemed inappropriate by the moderator.')
+
+class NonMemberPost(Errors.HoldMessage):
+ reason = _('Post by non-member to a members-only list')
+ rejection = _('Non-members are not allowed to post messages to this list.')
+
+class NotExplicitlyAllowed(Errors.HoldMessage):
+ reason = _('Posting to a restricted list by sender requires approval')
+ rejection = _('This list is restricted; your message was not approved.')
+
+class TooManyRecipients(Errors.HoldMessage):
+ reason = _('Too many recipients to the message')
+ rejection = _('Please trim the recipient list; it is too long.')
+
+class ImplicitDestination(Errors.HoldMessage):
+ reason = _('Message has implicit destination')
+ rejection = _('''Blind carbon copies or other implicit destinations are
+not allowed. Try reposting your message by explicitly including the list
+address in the To: or Cc: fields.''')
+
+class Administrivia(Errors.HoldMessage):
+ reason = _('Message may contain administrivia')
+
+ def rejection_notice(self, mlist):
+ listurl = mlist.GetScriptURL('listinfo', absolute=1)
+ request = mlist.GetRequestEmail()
+ return _("""Please do *not* post administrative requests to the mailing
+list. If you wish to subscribe, visit %(listurl)s or send a message with the
+word `help' in it to the request address, %(request)s, for further
+instructions.""")
+
+class SuspiciousHeaders(Errors.HoldMessage):
+ reason = _('Message has a suspicious header')
+ rejection = _('Your message had a suspicious header.')
+
+class MessageTooBig(Errors.HoldMessage):
+ def __init__(self, msgsize, limit):
+ self.__msgsize = msgsize
+ self.__limit = limit
+
+ def reason_notice(self):
+ size = self.__msgsize
+ limit = self.__limit
+ return _('''Message body is too big: %(size)d bytes with a limit of
+%(limit)d KB''')
+
+ def rejection_notice(self, mlist):
+ kb = self.__limit
+ return _('''Your message was too big; please trim it to less than
+%(kb)d KB in size.''')
+
+class ModeratedNewsgroup(ModeratedPost):
+ reason = _('Posting to a moderated newsgroup')
+
+
+
+# And reset the translator
+_ = i18n._
+
+
+
+def ackp(msg):
+ ack = msg.get('x-ack', '').lower()
+ precedence = msg.get('precedence', '').lower()
+ if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'):
+ return 0
+ return 1
+
+
+
+def process(mlist, msg, msgdata):
+ if msgdata.get('approved'):
+ return
+ # Get the sender of the message
+ listname = mlist.internal_name()
+ adminaddr = listname + '-admin'
+ sender = msg.get_sender()
+ # Special case an ugly sendmail feature: If there exists an alias of the
+ # form "owner-foo: bar" and sendmail receives mail for address "foo",
+ # sendmail will change the envelope sender of the message to "bar" before
+ # delivering. This feature does not appear to be configurable. *Boggle*.
+ if not sender or sender[:len(listname)+6] == adminaddr:
+ sender = msg.get_sender(use_envelope=0)
+ #
+ # Possible administrivia?
+ if mlist.administrivia and Utils.is_administrivia(msg):
+ hold_for_approval(mlist, msg, msgdata, Administrivia)
+ # no return
+ #
+ # Are there too many recipients to the message?
+ if mlist.max_num_recipients > 0:
+ # figure out how many recipients there are
+ recips = email.Utils.getaddresses(msg.get_all('to', []) +
+ msg.get_all('cc', []))
+ if len(recips) >= mlist.max_num_recipients:
+ hold_for_approval(mlist, msg, msgdata, TooManyRecipients)
+ # no return
+ #
+ # 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 msgdata.get('fromusenet'):
+ # then
+ hold_for_approval(mlist, msg, msgdata, ImplicitDestination)
+ # no return
+ #
+ # Suspicious headers?
+ if mlist.bounce_matching_headers:
+ triggered = mlist.hasMatchingHeader(msg)
+ if triggered:
+ # TBD: Darn - can't include the matching line for the admin
+ # message because the info would also go to the sender
+ hold_for_approval(mlist, msg, msgdata, SuspiciousHeaders)
+ # no return
+ #
+ # Is the message too big?
+ if mlist.max_message_size > 0:
+ bodylen = 0
+ for line in email.Iterators.body_line_iterator(msg):
+ bodylen += len(line)
+ if bodylen/1024.0 > mlist.max_message_size:
+ hold_for_approval(mlist, msg, msgdata,
+ MessageTooBig(bodylen, mlist.max_message_size))
+ # no return
+ #
+ # Are we gatewaying to a moderated newsgroup and is this list the
+ # moderator's address for the group?
+ if mlist.news_moderation == 2:
+ hold_for_approval(mlist, msg, msgdata, ModeratedNewsgroup)
+
+
+
+def hold_for_approval(mlist, msg, msgdata, exc):
+ # BAW: This should really be tied into the email confirmation system so
+ # that the message can be approved or denied via email as well as the
+ # web.
+ if type(exc) is ClassType:
+ # Go ahead and instantiate it now.
+ exc = exc()
+ listname = mlist.real_name
+ sender = msgdata.get('sender', msg.get_sender())
+ owneraddr = mlist.GetOwnerEmail()
+ adminaddr = mlist.GetBouncesEmail()
+ requestaddr = mlist.GetRequestEmail()
+ # We need to send both the reason and the rejection notice through the
+ # translator again, because of the games we play above
+ reason = Utils.wrap(exc.reason_notice())
+ msgdata['rejection_notice'] = Utils.wrap(exc.rejection_notice(mlist))
+ id = mlist.HoldMessage(msg, reason, msgdata)
+ # Now we need to craft and send a message to the list admin so they can
+ # deal with the held message.
+ d = {'listname' : listname,
+ 'hostname' : mlist.host_name,
+ 'reason' : _(reason),
+ 'sender' : sender,
+ 'subject' : msg.get('subject', _('(no subject)')),
+ 'admindb_url': mlist.GetScriptURL('admindb', absolute=1),
+ }
+ # We may want to send a notification to the original sender too
+ fromusenet = msgdata.get('fromusenet')
+ # Since we're sending two messages, which may potentially be in different
+ # languages (the user's preferred and the list's preferred for the admin),
+ # we need to play some i18n games here. Since the current language
+ # context ought to be set up for the user, let's craft his message first.
+ #
+ # This message should appear to come from <list>-admin so as to handle any
+ # bounce processing that might be needed.
+ cookie = Pending.new(Pending.HELD_MESSAGE, id)
+ if not fromusenet and ackp(msg) and mlist.respond_to_post_requests and \
+ mlist.autorespondToSender(sender):
+ # Get a confirmation cookie
+ d['confirmurl'] = '%s/%s' % (mlist.GetScriptURL('confirm', absolute=1),
+ cookie)
+ lang = msgdata.get('lang', mlist.getMemberLanguage(sender))
+ subject = _('Your message to %(listname)s awaits moderator approval')
+ text = Utils.maketext('postheld.txt', d, lang=lang, mlist=mlist)
+ nmsg = Message.UserNotification(sender, adminaddr, subject, text, lang)
+ nmsg.send(mlist)
+ # Now the message for the list owners. Be sure to include the list
+ # moderators in this message. This one should appear to come from
+ # <list>-owner since we really don't need to do bounce processing on it.
+ if mlist.admin_immed_notify:
+ # Now let's temporarily set the language context to that which the
+ # admin is expecting.
+ otranslation = i18n.get_translation()
+ i18n.set_language(mlist.preferred_language)
+ try:
+ lang = mlist.preferred_language
+ charset = Utils.GetCharSet(lang)
+ # We need to regenerate or re-translate a few values in d
+ usersubject = msg.get('subject', _('(no subject)'))
+ d['reason'] = _(reason)
+ d['subject'] = usersubject
+ # craft the admin notification message and deliver it
+ subject = _('%(listname)s post from %(sender)s requires approval')
+ nmsg = Message.UserNotification(owneraddr, owneraddr, subject,
+ lang=lang)
+ nmsg.set_type('multipart/mixed')
+ text = MIMEText(
+ Utils.maketext('postauth.txt', d, raw=1, mlist=mlist),
+ _charset=charset)
+ dmsg = MIMEText(Utils.wrap(_("""\
+If you reply to this message, keeping the Subject: header intact, Mailman will
+discard the held message. Do this if the message is spam. If you reply to
+this message and include an Approved: header with the list password in it, the
+message will be approved for posting to the list. The Approved: header can
+also appear in the first line of the body of the reply.""")),
+ _charset=Utils.GetCharSet(lang))
+ dmsg['Subject'] = 'confirm ' + cookie
+ dmsg['Sender'] = requestaddr
+ dmsg['From'] = requestaddr
+ nmsg.attach(text)
+ nmsg.attach(MIMEMessage(msg))
+ nmsg.attach(MIMEMessage(dmsg))
+ nmsg.send(mlist, **{'tomoderators': 1})
+ finally:
+ i18n.set_translation(otranslation)
+ # Log the held message
+ syslog('vette', '%s post from %s held: %s', listname, sender, reason)
+ # raise the specific MessageHeld exception to exit out of the message
+ # delivery pipeline
+ raise exc
diff --git a/Mailman/Handlers/Makefile.in b/Mailman/Handlers/Makefile.in
new file mode 100644
index 00000000..6123bdfb
--- /dev/null
+++ b/Mailman/Handlers/Makefile.in
@@ -0,0 +1,69 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/Handlers
+SHELL= /bin/sh
+
+MODULES= *.py
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
diff --git a/Mailman/Handlers/MimeDel.py b/Mailman/Handlers/MimeDel.py
new file mode 100644
index 00000000..3bcdaffa
--- /dev/null
+++ b/Mailman/Handlers/MimeDel.py
@@ -0,0 +1,220 @@
+# Copyright (C) 2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""MIME-stripping filter for Mailman.
+
+This module scans a message for MIME content, removing those sections whose
+MIME types match one of a list of matches. multipart/alternative sections are
+replaced by the first non-empty component, and multipart/mixed sections
+wrapping only single sections after other processing are replaced by their
+contents.
+"""
+
+import os
+import errno
+import tempfile
+
+from email.Iterators import typed_subpart_iterator
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman.Message import UserNotification
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Logging.Syslog import syslog
+from Mailman.Version import VERSION
+from Mailman.i18n import _
+
+
+
+def process(mlist, msg, msgdata):
+ # Short-circuits
+ if not mlist.filter_content:
+ return
+ if msgdata.get('isdigest'):
+ return
+ # We also don't care about our own digests or plaintext
+ ctype = msg.get_content_type()
+ mtype = msg.get_content_maintype()
+ # Check to see if the outer type matches one of the filter types
+ filtertypes = mlist.filter_mime_types
+ passtypes = mlist.pass_mime_types
+ if ctype in filtertypes or mtype in filtertypes:
+ dispose(mlist, msg, msgdata,
+ _("The message's content type was explicitly disallowed"))
+ # Check to see if there is a pass types and the outer type doesn't match
+ # one of these types
+ if passtypes and not (ctype in passtypes or mtype in passtypes):
+ dispose(mlist, msg, msgdata,
+ _("The message's content type was not explicitly allowed"))
+ numparts = len([subpart for subpart in msg.walk()])
+ # If the message is a multipart, filter out matching subparts
+ if msg.is_multipart():
+ # Recursively filter out any subparts that match the filter list
+ prelen = len(msg.get_payload())
+ filter_parts(msg, filtertypes, passtypes)
+ # If the outer message is now an empty multipart (and it wasn't
+ # before!) then, again it gets discarded.
+ postlen = len(msg.get_payload())
+ if postlen == 0 and prelen > 0:
+ dispose(mlist, msg, msgdata,
+ _("After content filtering, the message was empty"))
+ # Now replace all multipart/alternatives with just the first non-empty
+ # alternative. BAW: We have to special case when the outer part is a
+ # multipart/alternative because we need to retain most of the outer part's
+ # headers. For now we'll move the subpart's payload into the outer part,
+ # and then copy over its Content-Type: and Content-Transfer-Encoding:
+ # headers (any others?).
+ collapse_multipart_alternatives(msg)
+ if ctype == 'multipart/alternative':
+ firstalt = msg.get_payload(0)
+ reset_payload(msg, firstalt)
+ # If we removed some parts, make note of this
+ changedp = 0
+ if numparts <> len([subpart for subpart in msg.walk()]):
+ changedp = 1
+ # Now perhaps convert all text/html to text/plain
+ if mlist.convert_html_to_plaintext and mm_cfg.HTML_TO_PLAIN_TEXT_COMMAND:
+ changedp += to_plaintext(msg)
+ # If we're left with only two parts, an empty body and one attachment,
+ # recast the message to one of just that part
+ if msg.is_multipart() and len(msg.get_payload()) == 2:
+ if msg.get_payload(0).get_payload() == '':
+ useful = msg.get_payload(1)
+ reset_payload(msg, useful)
+ changedp = 1
+ if changedp:
+ msg['X-Content-Filtered-By'] = 'Mailman/MimeDel %s' % VERSION
+
+
+
+def reset_payload(msg, subpart):
+ # Reset payload of msg to contents of subpart, and fix up content headers
+ payload = subpart.get_payload()
+ msg.set_payload(payload)
+ del msg['content-type']
+ del msg['content-transfer-encoding']
+ del msg['content-disposition']
+ del msg['content-description']
+ msg['Content-Type'] = subpart.get('content-type', 'text/plain')
+ cte = subpart.get('content-transfer-encoding')
+ if cte:
+ msg['Content-Transfer-Encoding'] = cte
+ cdisp = subpart.get('content-disposition')
+ if cdisp:
+ msg['Content-Disposition'] = cdisp
+ cdesc = subpart.get('content-description')
+ if cdesc:
+ msg['Content-Description'] = cdesc
+
+
+
+def filter_parts(msg, filtertypes, passtypes):
+ # Look at all the message's subparts, and recursively filter
+ if not msg.is_multipart():
+ return 1
+ payload = msg.get_payload()
+ prelen = len(payload)
+ newpayload = []
+ for subpart in payload:
+ keep = filter_parts(subpart, filtertypes, passtypes)
+ if not keep:
+ continue
+ ctype = subpart.get_content_type()
+ mtype = subpart.get_content_maintype()
+ if ctype in filtertypes or mtype in filtertypes:
+ # Throw this subpart away
+ continue
+ if passtypes and not (ctype in passtypes or mtype in passtypes):
+ # Throw this subpart away
+ continue
+ newpayload.append(subpart)
+ # Check to see if we discarded all the subparts
+ postlen = len(newpayload)
+ msg.set_payload(newpayload)
+ if postlen == 0 and prelen > 0:
+ # We threw away everything
+ return 0
+ return 1
+
+
+
+def collapse_multipart_alternatives(msg):
+ if not msg.is_multipart():
+ return
+ newpayload = []
+ for subpart in msg.get_payload():
+ if subpart.get_content_type() == 'multipart/alternative':
+ try:
+ firstalt = subpart.get_payload(0)
+ newpayload.append(firstalt)
+ except IndexError:
+ pass
+ else:
+ newpayload.append(subpart)
+ msg.set_payload(newpayload)
+
+
+
+def to_plaintext(msg):
+ changedp = 0
+ for subpart in typed_subpart_iterator(msg, 'text', 'html'):
+ filename = tempfile.mktemp('.html')
+ fp = open(filename, 'w')
+ try:
+ fp.write(subpart.get_payload())
+ fp.close()
+ cmd = os.popen(mm_cfg.HTML_TO_PLAIN_TEXT_COMMAND %
+ {'filename': filename})
+ plaintext = cmd.read()
+ rtn = cmd.close()
+ if rtn:
+ syslog('error', 'HTML->text/plain error: %s', rtn)
+ finally:
+ try:
+ os.unlink(filename)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ # Now replace the payload of the subpart and twiddle the Content-Type:
+ subpart.set_payload(plaintext)
+ subpart.set_type('text/plain')
+ changedp = 1
+ return changedp
+
+
+
+def dispose(mlist, msg, msgdata, why):
+ # filter_action == 0 just discards, see below
+ if mlist.filter_action == 1:
+ # Bounce the message to the original author
+ raise Errors.RejectMessage, why
+ if mlist.filter_action == 2:
+ # Forward it on to the list owner
+ listname = mlist.internal_name()
+ mlist.ForwardMessage(
+ msg,
+ text=_("""\
+The attached message matched the %(listname)s mailing list's content filtering
+rules and was prevented from being forwarded on to the list membership. You
+are receiving the only remaining copy of the discarded message.
+
+"""),
+ subject=_('Content filtered message notification'))
+ if mlist.filter_action == 3 and \
+ mm_cfg.OWNERS_CAN_PRESERVE_FILTERED_MESSAGES:
+ badq = get_switchboard(mm_cfg.BADQUEUE_DIR)
+ badq.enqueue(msg, msgdata)
+ # Most cases also discard the message
+ raise Errors.DiscardMessage
diff --git a/Mailman/Handlers/Moderate.py b/Mailman/Handlers/Moderate.py
new file mode 100644
index 00000000..d44cb89b
--- /dev/null
+++ b/Mailman/Handlers/Moderate.py
@@ -0,0 +1,164 @@
+# Copyright (C) 2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Posting moderation filter.
+"""
+
+import re
+from email.MIMEMessage import MIMEMessage
+from email.MIMEText import MIMEText
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import Errors
+from Mailman.i18n import _
+from Mailman.Handlers import Hold
+from Mailman.Logging.Syslog import syslog
+
+
+
+class ModeratedMemberPost(Hold.ModeratedPost):
+ # BAW: I wanted to use the reason below to differentiate between this
+ # situation and normal ModeratedPost reasons. Greg Ward and Stonewall
+ # Ballard thought the language was too harsh and mentioned offense taken
+ # by some list members. I'd still like this class's reason to be
+ # different than the base class's reason, but we'll use this until someone
+ # can come up with something more clever but inoffensive.
+ #
+ # reason = _('Posts by member are currently quarantined for moderation')
+ pass
+
+
+
+def process(mlist, msg, msgdata):
+ if msgdata.get('approved'):
+ return
+ # First of all, is the poster a member or not?
+ for sender in msg.get_senders():
+ if mlist.isMember(sender):
+ break
+ else:
+ sender = None
+ if sender:
+ # If the member's moderation flag is on, then perform the moderation
+ # action.
+ if mlist.getMemberOption(sender, mm_cfg.Moderate):
+ # Note that for member_moderation_action, 0==Hold, 1=Reject,
+ # 2==Discard
+ if mlist.member_moderation_action == 0:
+ # Hold. BAW: WIBNI we could add the member_moderation_notice
+ # to the notice sent back to the sender?
+ msgdata['sender'] = sender
+ Hold.hold_for_approval(mlist, msg, msgdata,
+ ModeratedMemberPost)
+ elif mlist.member_moderation_action == 1:
+ # Reject
+ text = mlist.member_moderation_notice
+ if text:
+ text = Utils.wrap(text)
+ else:
+ # Use the default RejectMessage notice string
+ text = None
+ raise Errors.RejectMessage, text
+ elif mlist.member_moderation_action == 2:
+ # Discard. BAW: Again, it would be nice if we could send a
+ # discard notice to the sender
+ raise Errors.DiscardMessage
+ else:
+ assert 0, 'bad member_moderation_action'
+ # Should we do anything explict to mark this message as getting past
+ # this point? No, because further pipeline handlers will need to do
+ # their own thing.
+ return
+ else:
+ sender = msg.get_sender()
+ # From here on out, we're dealing with non-members.
+ if matches_p(sender, mlist.accept_these_nonmembers):
+ return
+ if matches_p(sender, mlist.hold_these_nonmembers):
+ Hold.hold_for_approval(mlist, msg, msgdata, Hold.NonMemberPost)
+ # No return
+ if matches_p(sender, mlist.reject_these_nonmembers):
+ do_reject(mlist)
+ # No return
+ if matches_p(sender, mlist.discard_these_nonmembers):
+ do_discard(mlist, msg)
+ # No return
+ # Okay, so the sender wasn't specified explicitly by any of the non-member
+ # moderation configuration variables. Handle by way of generic non-member
+ # action.
+ assert 0 <= mlist.generic_nonmember_action <= 4
+ if mlist.generic_nonmember_action == 0:
+ # Accept
+ return
+ elif mlist.generic_nonmember_action == 1:
+ Hold.hold_for_approval(mlist, msg, msgdata, Hold.NonMemberPost)
+ elif mlist.generic_nonmember_action == 2:
+ do_reject(mlist)
+ elif mlist.generic_nonmember_action == 3:
+ do_discard(mlist, msg)
+
+
+
+def matches_p(sender, nonmembers):
+ # First strip out all the regular expressions
+ plainaddrs = [addr for addr in nonmembers if not addr.startswith('^')]
+ addrdict = Utils.List2Dict(plainaddrs, foldcase=1)
+ if addrdict.has_key(sender):
+ return 1
+ # Now do the regular expression matches
+ for are in nonmembers:
+ if are.startswith('^'):
+ try:
+ cre = re.compile(are, re.IGNORECASE)
+ except re.error:
+ continue
+ if cre.search(sender):
+ return 1
+ return 0
+
+
+
+def do_reject(mlist):
+ listowner = mlist.GetOwnerEmail()
+ raise Errors.RejectMessage, Utils.wrap(_("""\
+You are not allowed to post to this mailing list, and your message has been
+automatically rejected. If you think that your messages are being rejected in
+error, contact the mailing list owner at %(listowner)s."""))
+
+
+
+def do_discard(mlist, msg):
+ sender = msg.get_sender()
+ # Do we forward auto-discards to the list owners?
+ if mlist.forward_auto_discards:
+ lang = mlist.preferred_language
+ varhelp = '%s/?VARHELP=privacy/sender/discard_these_nonmembers' % \
+ mlist.GetScriptURL('admin', absolute=1)
+ nmsg = Message.UserNotification(mlist.GetOwnerEmail(),
+ mlist.GetBouncesEmail(),
+ _('Auto-discard notification'),
+ lang=lang)
+ nmsg.set_type('multipart/mixed')
+ text = MIMEText(Utils.wrap(_(
+ 'The attached message has been automatically discarded.')),
+ _charset=Utils.GetCharSet(lang))
+ nmsg.attach(text)
+ nmsg.attach(MIMEMessage(msg))
+ nmsg.send(mlist)
+ # Discard this sucker
+ raise Errors.DiscardMessage
diff --git a/Mailman/Handlers/OwnerRecips.py b/Mailman/Handlers/OwnerRecips.py
new file mode 100644
index 00000000..c0a54f3d
--- /dev/null
+++ b/Mailman/Handlers/OwnerRecips.py
@@ -0,0 +1,27 @@
+# Copyright (C) 2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Calculate the list owner recipients (includes moderators).
+"""
+
+
+
+def process(mlist, msg, msgdata):
+ # The recipients are the owner and the moderator
+ msgdata['recips'] = mlist.owner + mlist.moderator
+ # Don't decorate these messages with the header/footers
+ msgdata['nodecorate'] = 1
+ msgdata['personalize'] = 0
diff --git a/Mailman/Handlers/Replybot.py b/Mailman/Handlers/Replybot.py
new file mode 100644
index 00000000..8a9be5cb
--- /dev/null
+++ b/Mailman/Handlers/Replybot.py
@@ -0,0 +1,120 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Handler for auto-responses.
+"""
+
+import time
+
+from Mailman import Utils
+from Mailman import Message
+from Mailman.i18n import _
+from Mailman.SafeDict import SafeDict
+from Mailman.Logging.Syslog import syslog
+
+
+
+def process(mlist, msg, msgdata):
+ # Normally, the replybot should get a shot at this message, but there are
+ # some important short-circuits, mostly to suppress 'bot storms, at least
+ # for well behaved email bots (there are other governors for misbehaving
+ # 'bots). First, if the original message has an "X-Ack: No" header, we
+ # skip the replybot. Then, if the message has a Precedence header with
+ # values bulk, junk, or list, and there's no explicit "X-Ack: yes" header,
+ # we short-circuit. Finally, if the message metadata has a true 'noack'
+ # key, then we skip the replybot too.
+ ack = msg.get('x-ack', '').lower()
+ if ack == 'no' or msgdata.get('noack'):
+ return
+ precedence = msg.get('precedence', '').lower()
+ if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'):
+ return
+ # Check to see if the list is even configured to autorespond to this email
+ # message. Note: the mailowner script sets the `toadmin' or `toowner' key
+ # (which for replybot purposes are equivalent), and the mailcmd script
+ # sets the `torequest' key.
+ toadmin = msgdata.get('toowner')
+ torequest = msgdata.get('torequest')
+ if ((toadmin and not mlist.autorespond_admin) or
+ (torequest and not mlist.autorespond_requests) or \
+ (not toadmin and not torequest and not mlist.autorespond_postings)):
+ return
+ # Now see if we're in the grace period for this sender. graceperiod <= 0
+ # means always autorespond, as does an "X-Ack: yes" header (useful for
+ # debugging).
+ sender = msg.get_sender()
+ now = time.time()
+ graceperiod = mlist.autoresponse_graceperiod
+ if graceperiod > 0 and ack <> 'yes':
+ if toadmin:
+ quiet_until = mlist.admin_responses.get(sender, 0)
+ elif torequest:
+ quiet_until = mlist.request_responses.get(sender, 0)
+ else:
+ quiet_until = mlist.postings_responses.get(sender, 0)
+ if quiet_until > now:
+ return
+ #
+ # Okay, we know we're going to auto-respond to this sender, craft the
+ # message, send it, and update the database.
+ realname = mlist.real_name
+ subject = _('Auto-response for your message to ') + \
+ msg.get('to', _('the "%(realname)s" mailing list'))
+ # Do string interpolation
+ d = SafeDict({'listname' : realname,
+ 'listurl' : mlist.GetScriptURL('listinfo'),
+ 'requestemail': mlist.GetRequestEmail(),
+ # BAW: Deprecate adminemail; it's not advertised but still
+ # supported for backwards compatibility.
+ 'adminemail' : mlist.GetBouncesEmail(),
+ 'owneremail' : mlist.GetOwnerEmail(),
+ })
+ # Just because we're using a SafeDict doesn't mean we can't get all sorts
+ # of other exceptions from the string interpolation. Let's be ultra
+ # conservative here.
+ if toadmin:
+ rtext = mlist.autoresponse_admin_text
+ elif torequest:
+ rtext = mlist.autoresponse_request_text
+ else:
+ rtext = mlist.autoresponse_postings_text
+ # Using $-strings?
+ if getattr(mlist, 'use_dollar_strings', 0):
+ rtext = Utils.to_percent(rtext)
+ try:
+ text = rtext % d
+ except Exception:
+ syslog('error', 'Bad autoreply text for list: %s\n%s',
+ mlist.internal_name(), rtext)
+ text = rtext
+ # Wrap the response.
+ text = Utils.wrap(text)
+ outmsg = Message.UserNotification(sender, mlist.GetBouncesEmail(),
+ subject, text, mlist.preferred_language)
+ outmsg['X-Mailer'] = _('The Mailman Replybot')
+ # prevent recursions and mail loops!
+ outmsg['X-Ack'] = 'No'
+ outmsg.send(mlist)
+ # update the grace period database
+ if graceperiod > 0:
+ # graceperiod is in days, we need # of seconds
+ quiet_until = now + graceperiod * 24 * 60 * 60
+ if toadmin:
+ mlist.admin_responses[sender] = quiet_until
+ elif torequest:
+ mlist.request_responses[sender] = quiet_until
+ else:
+ mlist.postings_responses[sender] = quiet_until
diff --git a/Mailman/Handlers/SMTPDirect.py b/Mailman/Handlers/SMTPDirect.py
new file mode 100644
index 00000000..3033fdbd
--- /dev/null
+++ b/Mailman/Handlers/SMTPDirect.py
@@ -0,0 +1,349 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Local SMTP direct drop-off.
+
+This module delivers messages via SMTP to a locally specified daemon. This
+should be compatible with any modern SMTP server. It is expected that the MTA
+handles all final delivery. We have to play tricks so that the list object
+isn't locked while delivery occurs synchronously.
+
+Note: This file only handles single threaded delivery. See SMTPThreaded.py
+for a threaded implementation.
+"""
+
+import time
+import socket
+import smtplib
+from types import UnicodeType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.Handlers import Decorate
+from Mailman.Logging.Syslog import syslog
+from Mailman.SafeDict import MsgSafeDict
+
+import email
+from email.Utils import formataddr
+from email.Header import Header
+from email.Charset import Charset
+
+DOT = '.'
+
+
+
+# Manage a connection to the SMTP server
+class Connection:
+ def __init__(self):
+ self.__connect()
+
+ def __connect(self):
+ self.__conn = smtplib.SMTP()
+ self.__conn.connect(mm_cfg.SMTPHOST, mm_cfg.SMTPPORT)
+ self.__numsessions = mm_cfg.SMTP_MAX_SESSIONS_PER_CONNECTION
+
+ def sendmail(self, envsender, recips, msgtext):
+ try:
+ results = self.__conn.sendmail(envsender, recips, msgtext)
+ except smtplib.SMTPException:
+ # For safety, reconnect
+ self.__conn.quit()
+ self.__connect()
+ # Let exceptions percolate up
+ raise
+ # Decrement the session counter, reconnecting if necessary
+ self.__numsessions -= 1
+ # By testing exactly for equality to 0, we automatically handle the
+ # case for SMTP_MAX_SESSIONS_PER_CONNECTION <= 0 meaning never close
+ # the connection. We won't worry about wraparound <wink>.
+ if self.__numsessions == 0:
+ self.__conn.quit()
+ self.__connect()
+ return results
+
+ def quit(self):
+ self.__conn.quit()
+
+
+
+def process(mlist, msg, msgdata):
+ recips = msgdata.get('recips')
+ if not recips:
+ # Nobody to deliver to!
+ return
+ # Calculate the non-VERP envelope sender.
+ envsender = msgdata.get('envsender')
+ if envsender is None:
+ if mlist:
+ envsender = mlist.GetBouncesEmail()
+ else:
+ envsender = Utils.get_site_email(extra='bounces')
+ # Time to split up the recipient list. If we're personalizing or VERPing
+ # then each chunk will have exactly one recipient. We'll then hand craft
+ # an envelope sender and stitch a message together in memory for each one
+ # separately. If we're not VERPing, then we'll chunkify based on
+ # SMTP_MAX_RCPTS. Note that most MTAs have a limit on the number of
+ # recipients they'll swallow in a single transaction.
+ deliveryfunc = None
+ if (not msgdata.has_key('personalize') or msgdata['personalize']) and (
+ msgdata.get('verp') or mlist.personalize):
+ chunks = [[recip] for recip in recips]
+ msgdata['personalize'] = 1
+ deliveryfunc = verpdeliver
+ elif mm_cfg.SMTP_MAX_RCPTS <= 0:
+ chunks = [recips]
+ else:
+ chunks = chunkify(recips, mm_cfg.SMTP_MAX_RCPTS)
+ # See if this is an unshunted message for which some were undelivered
+ if msgdata.has_key('undelivered'):
+ chunks = msgdata['undelivered']
+ # If we're doing bulk delivery, then we can stitch up the message now.
+ if deliveryfunc is None:
+ # Be sure never to decorate the message more than once!
+ if not msgdata.get('decorated'):
+ Decorate.process(mlist, msg, msgdata)
+ msgdata['decorated'] = 1
+ deliveryfunc = bulkdeliver
+ refused = {}
+ t0 = time.time()
+ # Open the initial connection
+ origrecips = msgdata['recips']
+ # `undelivered' is a copy of chunks that we pop from to do deliveries.
+ # This seems like a good tradeoff between robustness and resource
+ # utilization. If delivery really fails (i.e. qfiles/shunt type
+ # failures), then we'll pick up where we left off with `undelivered'.
+ # This means at worst, the last chunk for which delivery was attempted
+ # could get duplicates but not every one, and no recips should miss the
+ # message.
+ conn = Connection()
+ try:
+ msgdata['undelivered'] = chunks
+ while chunks:
+ chunk = chunks.pop()
+ msgdata['recips'] = chunk
+ try:
+ deliveryfunc(mlist, msg, msgdata, envsender, refused, conn)
+ except Exception:
+ # If /anything/ goes wrong, push the last chunk back on the
+ # undelivered list and re-raise the exception. We don't know
+ # how many of the last chunk might receive the message, so at
+ # worst, everyone in this chunk will get a duplicate. Sigh.
+ chunks.append(chunk)
+ raise
+ del msgdata['undelivered']
+ finally:
+ conn.quit()
+ msgdata['recips'] = origrecips
+ # Log the successful post
+ t1 = time.time()
+ d = MsgSafeDict(msg, {'time' : t1-t0,
+ # BAW: Urg. This seems inefficient.
+ 'size' : len(msg.as_string()),
+ '#recips' : len(recips),
+ '#refused': len(refused),
+ 'listname': mlist.internal_name(),
+ 'sender' : msg.get_sender(),
+ })
+ # We have to use the copy() method because extended call syntax requires a
+ # concrete dictionary object; it does not allow a generic mapping. It's
+ # still worthwhile doing the interpolation in syslog() because it'll catch
+ # any catastrophic exceptions due to bogus format strings.
+ if mm_cfg.SMTP_LOG_EVERY_MESSAGE:
+ syslog.write_ex(mm_cfg.SMTP_LOG_EVERY_MESSAGE[0],
+ mm_cfg.SMTP_LOG_EVERY_MESSAGE[1], kws=d)
+
+ if refused:
+ if mm_cfg.SMTP_LOG_REFUSED:
+ syslog.write_ex(mm_cfg.SMTP_LOG_REFUSED[0],
+ mm_cfg.SMTP_LOG_REFUSED[1], kws=d)
+
+ elif msgdata.get('tolist'):
+ # Log the successful post, but only if it really was a post to the
+ # mailing list. Don't log sends to the -owner, or -admin addrs.
+ # -request addrs should never get here. BAW: it may be useful to log
+ # the other messages, but in that case, we should probably have a
+ # separate configuration variable to control that.
+ if mm_cfg.SMTP_LOG_SUCCESS:
+ syslog.write_ex(mm_cfg.SMTP_LOG_SUCCESS[0],
+ mm_cfg.SMTP_LOG_SUCCESS[1], kws=d)
+
+ # Process any failed deliveries.
+ tempfailures = []
+ permfailures = []
+ for recip, (code, smtpmsg) in refused.items():
+ # DRUMS is an internet draft, but it says:
+ #
+ # [RFC-821] incorrectly listed the error where an SMTP server
+ # exhausts its implementation limit on the number of RCPT commands
+ # ("too many recipients") as having reply code 552. The correct
+ # reply code for this condition is 452. Clients SHOULD treat a 552
+ # code in this case as a temporary, rather than permanent failure
+ # so the logic below works.
+ #
+ if code >= 500 and code <> 552:
+ # A permanent failure
+ permfailures.append(recip)
+ else:
+ # Deal with persistent transient failures by queuing them up for
+ # future delivery. TBD: this could generate lots of log entries!
+ tempfailures.append(recip)
+ if mm_cfg.SMTP_LOG_EACH_FAILURE:
+ d.update({'recipient': recip,
+ 'failcode' : code,
+ 'failmsg' : smtpmsg})
+ syslog.write_ex(mm_cfg.SMTP_LOG_EACH_FAILURE[0],
+ mm_cfg.SMTP_LOG_EACH_FAILURE[1], kws=d)
+ # Return the results
+ if tempfailures or permfailures:
+ raise Errors.SomeRecipientsFailed(tempfailures, permfailures)
+
+
+
+def chunkify(recips, chunksize):
+ # First do a simple sort on top level domain. It probably doesn't buy us
+ # much to try to sort on MX record -- that's the MTA's job. We're just
+ # trying to avoid getting a max recips error. Split the chunks along
+ # these lines (as suggested originally by Chuq Von Rospach and slightly
+ # elaborated by BAW).
+ chunkmap = {'com': 1,
+ 'net': 2,
+ 'org': 2,
+ 'edu': 3,
+ 'us' : 3,
+ 'ca' : 3,
+ }
+ buckets = {}
+ for r in recips:
+ tld = None
+ i = r.rfind('.')
+ if i >= 0:
+ tld = r[i+1:]
+ bin = chunkmap.get(tld, 0)
+ bucket = buckets.get(bin, [])
+ bucket.append(r)
+ buckets[bin] = bucket
+ # Now start filling the chunks
+ chunks = []
+ currentchunk = []
+ chunklen = 0
+ for bin in buckets.values():
+ for r in bin:
+ currentchunk.append(r)
+ chunklen = chunklen + 1
+ if chunklen >= chunksize:
+ chunks.append(currentchunk)
+ currentchunk = []
+ chunklen = 0
+ if currentchunk:
+ chunks.append(currentchunk)
+ currentchunk = []
+ chunklen = 0
+ return chunks
+
+
+
+def verpdeliver(mlist, msg, msgdata, envsender, failures, conn):
+ for recip in msgdata['recips']:
+ # We now need to stitch together the message with its header and
+ # footer. If we're VERPIng, we have to calculate the envelope sender
+ # for each recipient. Note that the list of recipients must be of
+ # length 1.
+ #
+ # BAW: ezmlm includes the message number in the envelope, used when
+ # sending a notification to the user telling her how many messages
+ # they missed due to bouncing. Neat idea.
+ msgdata['recips'] = [recip]
+ # Make a copy of the message and decorate + delivery that
+ msgcopy = email.message_from_string(msg.as_string())
+ Decorate.process(mlist, msgcopy, msgdata)
+ # Calculate the envelope sender, which we may be VERPing
+ if msgdata.get('verp'):
+ bmailbox, bdomain = Utils.ParseEmail(envsender)
+ rmailbox, rdomain = Utils.ParseEmail(recip)
+ d = {'bounces': bmailbox,
+ 'mailbox': rmailbox,
+ 'host' : DOT.join(rdomain),
+ }
+ envsender = '%s@%s' % ((mm_cfg.VERP_FORMAT % d), DOT.join(bdomain))
+ if mlist.personalize == 2:
+ # When fully personalizing, we want the To address to point to the
+ # recipient, not to the mailing list
+ del msgcopy['to']
+ name = None
+ if mlist.isMember(recip):
+ name = mlist.getMemberName(recip)
+ if name:
+ # Convert the name to an email-safe representation. If the
+ # name is a byte string, convert it first to Unicode, given
+ # the character set of the member's language, replacing bad
+ # characters for which we can do nothing about. Once we have
+ # the name as Unicode, we can create a Header instance for it
+ # so that it's properly encoded for email transport.
+ charset = Utils.GetCharSet(mlist.getMemberLanguage(recip))
+ if charset == 'us-ascii':
+ # Since Header already tries both us-ascii and utf-8,
+ # let's add something a bit more useful.
+ charset = 'iso-8859-1'
+ charset = Charset(charset)
+ codec = charset.input_codec or 'ascii'
+ if not isinstance(name, UnicodeType):
+ name = unicode(name, codec, 'replace')
+ name = Header(name, charset).encode()
+ msgcopy['To'] = formataddr((name, recip))
+ else:
+ msgcopy['To'] = recip
+ # We can flag the mail as a duplicate for each member, if they've
+ # already received this message, as calculated by Message-ID. See
+ # AvoidDuplicates.py for details.
+ del msgcopy['x-mailman-copy']
+ if msgdata.get('add-dup-header', {}).has_key(recip):
+ msgcopy['X-Mailman-Copy'] = 'yes'
+ # For the final delivery stage, we can just bulk deliver to a party of
+ # one. ;)
+ bulkdeliver(mlist, msgcopy, msgdata, envsender, failures, conn)
+
+
+
+def bulkdeliver(mlist, msg, msgdata, envsender, failures, conn):
+ # Do some final cleanup of the message header. Start by blowing away
+ # any the Sender: and Errors-To: headers so remote MTAs won't be
+ # tempted to delivery bounces there instead of our envelope sender
+ del msg['sender']
+ del msg['errors-to']
+ msg['Sender'] = envsender
+ msg['Errors-To'] = envsender
+ # Get the plain, flattened text of the message, sans unixfrom
+ msgtext = msg.as_string()
+ refused = {}
+ recips = msgdata['recips']
+ try:
+ # Send the message
+ refused = conn.sendmail(envsender, recips, msgtext)
+ except smtplib.SMTPRecipientsRefused, e:
+ refused = e.recipients
+ # MTA not responding, or other socket problems, or any other kind of
+ # SMTPException. In that case, nothing got delivered
+ except (socket.error, smtplib.SMTPException), e:
+ # BAW: should this be configurable?
+ syslog('smtp', 'All recipients refused: %s', e)
+ # If the exception had an associated error code, use it, otherwise,
+ # fake it with a non-triggering exception code
+ errcode = getattr(e, 'smtp_code', -1)
+ errmsg = getattr(e, 'smtp_error', 'ignore')
+ for r in recips:
+ refused[r] = (errcode, errmsg)
+ failures.update(refused)
diff --git a/Mailman/Handlers/Scrubber.py b/Mailman/Handlers/Scrubber.py
new file mode 100644
index 00000000..5dabadf3
--- /dev/null
+++ b/Mailman/Handlers/Scrubber.py
@@ -0,0 +1,400 @@
+# Copyright (C) 2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Cleanse a message for archiving.
+"""
+
+import os
+import re
+import sha
+import time
+import errno
+import binascii
+import tempfile
+import mimetypes
+from cStringIO import StringIO
+from types import IntType
+
+from email.Utils import parsedate
+from email.Parser import HeaderParser
+from email.Generator import Generator
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import LockFile
+from Mailman import Message
+from Mailman.Errors import DiscardMessage
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+
+# Path characters for common platforms
+pre = re.compile(r'[/\\:]')
+# All other characters to strip out of Content-Disposition: filenames
+# (essentially anything that isn't an alphanum, dot, slash, or underscore.
+sre = re.compile(r'[^-\w.]')
+# Regexp to strip out leading dots
+dre = re.compile(r'^\.*')
+
+BR = '<br>\n'
+SPACE = ' '
+
+
+
+# We're using a subclass of the standard Generator because we want to suppress
+# headers in the subparts of multiparts. We use a hack -- the ctor argument
+# skipheaders to accomplish this. It's set to true for the outer Message
+# object, but false for all internal objects. We recognize that
+# sub-Generators will get created passing only mangle_from_ and maxheaderlen
+# to the ctors.
+#
+# This isn't perfect because we still get stuff like the multipart boundaries,
+# but see below for how we corrupt that to our nefarious goals.
+class ScrubberGenerator(Generator):
+ def __init__(self, outfp, mangle_from_=1, maxheaderlen=78, skipheaders=1):
+ Generator.__init__(self, outfp, mangle_from_=0)
+ self.__skipheaders = skipheaders
+
+ def _write_headers(self, msg):
+ if not self.__skipheaders:
+ Generator._write_headers(self, msg)
+
+
+def safe_strftime(fmt, floatsecs):
+ try:
+ return time.strftime(fmt, floatsecs)
+ except ValueError:
+ return None
+
+
+def calculate_attachments_dir(mlist, msg, msgdata):
+ # Calculate the directory that attachments for this message will go
+ # under. To avoid inode limitations, the scheme will be:
+ # archives/private/<listname>/attachments/YYYYMMDD/<msgid-hash>/<files>
+ # Start by calculating the date-based and msgid-hash components.
+ fmt = '%Y%m%d'
+ datestr = msg.get('Date')
+ if datestr:
+ now = parsedate(datestr)
+ else:
+ now = time.gmtime(msgdata.get('received_time', time.time()))
+ datedir = safe_strftime(fmt, now)
+ if not datedir:
+ datestr = msgdata.get('X-List-Received-Date')
+ if datestr:
+ datedir = safe_strftime(fmt, datestr)
+ if not datedir:
+ # What next? Unixfrom, I guess.
+ parts = msg.get_unixfrom().split()
+ try:
+ month = {'Jan':1, 'Feb':2, 'Mar':3, 'Apr':4, 'May':5, 'Jun':6,
+ 'Jul':7, 'Aug':8, 'Sep':9, 'Oct':10, 'Nov':11, 'Dec':12,
+ }.get(parts[3], 0)
+ day = int(parts[4])
+ year = int(parts[6])
+ except (IndexError, ValueError):
+ # Best we can do I think
+ month = day = year = 0
+ datedir = '%04d%02d%02d' % (year, month, day)
+ assert datedir
+ # As for the msgid hash, we'll base this part on the Message-ID: so that
+ # all attachments for the same message end up in the same directory (we'll
+ # uniquify the filenames in that directory as needed). We use the first 2
+ # and last 2 bytes of the SHA1 hash of the message id as the basis of the
+ # directory name. Clashes here don't really matter too much, and that
+ # still gives us a 32-bit space to work with.
+ msgid = msg['message-id']
+ if msgid is None:
+ msgid = msg['Message-ID'] = Utils.unique_message_id(mlist)
+ # We assume that the message id actually /is/ unique!
+ digest = sha.new(msgid).hexdigest()
+ return os.path.join('attachments', datedir, digest[:4] + digest[-4:])
+
+
+
+def process(mlist, msg, msgdata=None):
+ sanitize = mm_cfg.ARCHIVE_HTML_SANITIZER
+ outer = 1
+ if msgdata is None:
+ msgdata = {}
+ dir = calculate_attachments_dir(mlist, msg, msgdata)
+ charset = None
+ # Now walk over all subparts of this message and scrub out various types
+ for part in msg.walk():
+ ctype = part.get_type(part.get_default_type())
+ # If the part is text/plain, we leave it alone
+ if ctype == 'text/plain':
+ # We need to choose a charset for the scrubbed message, so we'll
+ # arbitrarily pick the charset of the first text/plain part in the
+ # message.
+ if charset is None:
+ charset = part.get_content_charset(charset)
+ elif ctype == 'text/html' and isinstance(sanitize, IntType):
+ if sanitize == 0:
+ if outer:
+ raise DiscardMessage
+ part.set_payload(_('HTML attachment scrubbed and removed'))
+ part.set_type('text/plain')
+ elif sanitize == 2:
+ # By leaving it alone, Pipermail will automatically escape it
+ pass
+ elif sanitize == 3:
+ # Pull it out as an attachment but leave it unescaped. This
+ # is dangerous, but perhaps useful for heavily moderated
+ # lists.
+ omask = os.umask(002)
+ try:
+ url = save_attachment(mlist, part, dir, filter_html=0)
+ finally:
+ os.umask(omask)
+ part.set_payload(_("""\
+An HTML attachment was scrubbed...
+URL: %(url)s
+"""))
+ part.set_type('text/plain')
+ else:
+ # HTML-escape it and store it as an attachment, but make it
+ # look a /little/ bit prettier. :(
+ payload = Utils.websafe(part.get_payload(decode=1))
+ # For whitespace in the margin, change spaces into
+ # non-breaking spaces, and tabs into 8 of those. Then use a
+ # mono-space font. Still looks hideous to me, but then I'd
+ # just as soon discard them.
+ def doreplace(s):
+ return s.replace(' ', '&nbsp;').replace('\t', '&nbsp'*8)
+ lines = [doreplace(s) for s in payload.split('\n')]
+ payload = '<tt>\n' + BR.join(lines) + '\n</tt>\n'
+ part.set_payload(payload)
+ # We're replacing the payload with the decoded payload so this
+ # will just get in the way.
+ del part['content-transfer-encoding']
+ omask = os.umask(002)
+ try:
+ url = save_attachment(mlist, part, dir, filter_html=0)
+ finally:
+ os.umask(omask)
+ part.set_payload(_("""\
+An HTML attachment was scrubbed...
+URL: %(url)s
+"""))
+ part.set_type('text/plain')
+ elif ctype == 'message/rfc822':
+ # This part contains a submessage, so it too needs scrubbing
+ submsg = part.get_payload(0)
+ omask = os.umask(002)
+ try:
+ url = save_attachment(mlist, part, dir)
+ finally:
+ os.umask(omask)
+ subject = submsg.get('subject', _('no subject'))
+ date = submsg.get('date', _('no date'))
+ who = submsg.get('from', _('unknown sender'))
+ size = len(str(submsg))
+ part.set_payload(_("""\
+An embedded message was scrubbed...
+From: %(who)s
+Subject: %(subject)s
+Date: %(date)s
+Size: %(size)s
+Url: %(url)s
+"""))
+ part.set_type('text/plain')
+ # If the message isn't a multipart, then we'll strip it out as an
+ # attachment that would have to be separately downloaded. Pipermail
+ # will transform the url into a hyperlink.
+ elif not part.is_multipart():
+ payload = part.get_payload()
+ ctype = part.get_type()
+ size = len(payload)
+ omask = os.umask(002)
+ try:
+ url = save_attachment(mlist, part, dir)
+ finally:
+ os.umask(omask)
+ desc = part.get('content-description', _('not available'))
+ filename = part.get_filename(_('not available'))
+ part.set_payload(_("""\
+A non-text attachment was scrubbed...
+Name: %(filename)s
+Type: %(ctype)s
+Size: %(size)d bytes
+Desc: %(desc)s
+Url : %(url)s
+"""))
+ part.set_type('text/plain')
+ outer = 0
+ # We still have to sanitize multipart messages to flat text because
+ # Pipermail can't handle messages with list payloads. This is a kludge;
+ # def (n) clever hack ;).
+ if msg.is_multipart():
+ # By default we take the charset of the first text/plain part in the
+ # message, but if there was none, we'll use the list's preferred
+ # language's charset.
+ if charset is None:
+ charset = Utils.GetCharSet(mlist.preferred_language)
+ # We now want to concatenate all the parts which have been scrubbed to
+ # text/plain, into a single text/plain payload. We need to make sure
+ # all the characters in the concatenated string are in the same
+ # encoding, so we'll use the 'replace' key in the coercion call.
+ # BAW: Martin's original patch suggested we might want to try
+ # generalizing to utf-8, and that's probably a good idea (eventually).
+ text = []
+ for part in msg.get_payload():
+ # All parts should be scrubbed to text/plain by now.
+ partctype = part.get_content_type()
+ if partctype <> 'text/plain':
+ text.append(_('Skipped content of type %(partctype)s'))
+ continue
+ try:
+ t = part.get_payload(decode=1)
+ except binascii.Error:
+ t = part.get_payload()
+ partcharset = part.get_charset()
+ if partcharset and partcharset <> charset:
+ try:
+ t = unicode(t, partcharset, 'replace')
+ # Should use HTML-Escape, or try generalizing to UTF-8
+ t = t.encode(charset, 'replace')
+ except UnicodeError:
+ # Replace funny characters
+ t = unicode(t, 'ascii', 'replace').encode('ascii')
+ text.append(t)
+ # Now join the text and set the payload
+ sep = _('-------------- next part --------------\n')
+ msg.set_payload(sep.join(text), charset)
+ msg.set_type('text/plain')
+ del msg['content-transfer-encoding']
+ msg.add_header('Content-Transfer-Encoding', '8bit')
+ return msg
+
+
+
+def makedirs(dir):
+ # Create all the directories to store this attachment in
+ try:
+ os.makedirs(dir, 02775)
+ except OSError, e:
+ if e.errno <> errno.EEXIST: raise
+ # Unfortunately, FreeBSD seems to be broken in that it doesn't honor the
+ # mode arg of mkdir().
+ def twiddle(arg, dirname, names):
+ os.chmod(dirname, 02775)
+ os.path.walk(dir, twiddle, None)
+
+
+
+def save_attachment(mlist, msg, dir, filter_html=1):
+ fsdir = os.path.join(mlist.archive_dir(), dir)
+ makedirs(fsdir)
+ # Figure out the attachment type and get the decoded data
+ decodedpayload = msg.get_payload(decode=1)
+ # BAW: mimetypes ought to handle non-standard, but commonly found types,
+ # e.g. image/jpg (should be image/jpeg). For now we just store such
+ # things as application/octet-streams since that seems the safest.
+ ext = mimetypes.guess_extension(msg.get_type())
+ if not ext:
+ # We don't know what it is, so assume it's just a shapeless
+ # application/octet-stream, unless the Content-Type: is
+ # message/rfc822, in which case we know we'll coerce the type to
+ # text/plain below.
+ if msg.get_type() == 'message/rfc822':
+ ext = '.txt'
+ else:
+ ext = '.bin'
+ path = None
+ # We need a lock to calculate the next attachment number
+ lockfile = os.path.join(fsdir, 'attachments.lock')
+ lock = LockFile.LockFile(lockfile)
+ lock.lock()
+ try:
+ # Now base the filename on what's in the attachment, uniquifying it if
+ # necessary.
+ filename = msg.get_filename()
+ if not filename:
+ filebase = 'attachment'
+ else:
+ # Sanitize the filename given in the message headers
+ parts = pre.split(filename)
+ filename = parts[-1]
+ # Strip off leading dots
+ filename = dre.sub('', filename)
+ # Allow only alphanumerics, dash, underscore, and dot
+ filename = sre.sub('', filename)
+ # If the filename's extension doesn't match the type we guessed,
+ # which one should we go with? For now, let's go with the one we
+ # guessed so attachments can't lie about their type. Also, if the
+ # filename /has/ no extension, then tack on the one we guessed.
+ filebase, ignore = os.path.splitext(filename)
+ # Now we're looking for a unique name for this file on the file
+ # system. If msgdir/filebase.ext isn't unique, we'll add a counter
+ # after filebase, e.g. msgdir/filebase-cnt.ext
+ counter = 0
+ extra = ''
+ while 1:
+ path = os.path.join(fsdir, filebase + extra + ext)
+ # Generally it is not a good idea to test for file existance
+ # before just trying to create it, but the alternatives aren't
+ # wonderful (i.e. os.open(..., O_CREAT | O_EXCL) isn't
+ # NFS-safe). Besides, we have an exclusive lock now, so we're
+ # guaranteed that no other process will be racing with us.
+ if os.path.exists(path):
+ counter += 1
+ extra = '-%04d' % counter
+ else:
+ break
+ finally:
+ lock.unlock()
+ # `path' now contains the unique filename for the attachment. There's
+ # just one more step we need to do. If the part is text/html and
+ # ARCHIVE_HTML_SANITIZER is a string (which it must be or we wouldn't be
+ # here), then send the attachment through the filter program for
+ # sanitization
+ if filter_html and msg.get_type() == 'text/html':
+ base, ext = os.path.splitext(path)
+ tmppath = base + '-tmp' + ext
+ fp = open(tmppath, 'w')
+ try:
+ fp.write(decodedpayload)
+ fp.close()
+ cmd = mm_cfg.ARCHIVE_HTML_SANITIZER % {'filename' : tmppath}
+ progfp = os.popen(cmd, 'r')
+ decodedpayload = progfp.read()
+ status = progfp.close()
+ if status:
+ syslog('error',
+ 'HTML sanitizer exited with non-zero status: %s',
+ status)
+ finally:
+ os.unlink(tmppath)
+ # BAW: Since we've now sanitized the document, it should be plain
+ # text. Blarg, we really want the sanitizer to tell us what the type
+ # if the return data is. :(
+ ext = '.txt'
+ path = base + '.txt'
+ # Is it a message/rfc822 attachment?
+ elif msg.get_type() == 'message/rfc822':
+ submsg = msg.get_payload()
+ # BAW: I'm sure we can eventually do better than this. :(
+ decodedpayload = Utils.websafe(str(submsg))
+ fp = open(path, 'w')
+ fp.write(decodedpayload)
+ fp.close()
+ # Now calculate the url
+ baseurl = mlist.GetBaseArchiveURL()
+ # Private archives will likely have a trailing slash. Normalize.
+ if baseurl[-1] <> '/':
+ baseurl += '/'
+ url = baseurl + '%s/%s%s%s' % (dir, filebase, extra, ext)
+ return url
diff --git a/Mailman/Handlers/Sendmail.py b/Mailman/Handlers/Sendmail.py
new file mode 100644
index 00000000..8bd88697
--- /dev/null
+++ b/Mailman/Handlers/Sendmail.py
@@ -0,0 +1,116 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Deliver a message via command-line drop-off.
+
+WARNING WARNING WARNING: This module is provided for example purposes only.
+It should not be used in a production environment for reasons described
+below. Because of this, you must explicitly enable it with by editing the
+code. See the WARN section in the process() function.
+
+This module delivers the message via the command line interface to the
+sendmail program. It should work for sendmail clones like Postfix. It is
+expected that sendmail handles final delivery, message queueing, etc. The
+recipient list is only trivially split so that the command line is less than
+about 3k in size.
+
+SECURITY WARNING: Because this module uses os.popen(), it goes through the
+shell. This module does not scan the arguments for potential exploits and so
+it should be considered unsafe for production use. For performance reasons,
+it's not recommended either -- use the SMTPDirect delivery module instead,
+even if you're using the sendmail MTA.
+
+DUPLICATES WARNING: Using this module can cause duplicates to be delivered to
+your membership, depending on your MTA! E.g. It is known that if you're using
+the sendmail MTA, and if a message contains a single dot on a line by itself,
+your list members will receive many duplicates.
+"""
+
+import string
+import os
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman.Logging.Syslog import syslog
+
+MAX_CMDLINE = 3000
+
+
+
+def process(mlist, msg, msgdata):
+ """Process the message object for the given list.
+
+ The message object is an instance of Mailman.Message and must be fully
+ prepared for delivery (i.e. all the appropriate headers must be set). The
+ message object can have the following attributes:
+
+ recips - the list of recipients for the message (required)
+
+ This function processes the message by handing off the delivery of the
+ message to a sendmail (or sendmail clone) program. It can raise a
+ SendmailHandlerError if an error status was returned by the sendmail
+ program.
+
+ """
+ # WARN: If you've read the warnings above and /still/ insist on using this
+ # module, you must comment out the following line. I still recommend you
+ # don't do this!
+ assert 0, 'Use of the Sendmail.py delivery module is highly discouraged'
+ recips = msgdata.get('recips')
+ if not recips:
+ # Nobody to deliver to!
+ return
+ # Use -f to set the envelope sender
+ cmd = mm_cfg.SENDMAIL_CMD + ' -f ' + mlist.GetBouncesEmail() + ' '
+ # make sure the command line is of a manageable size
+ recipchunks = []
+ currentchunk = []
+ chunklen = 0
+ for r in recips:
+ currentchunk.append(r)
+ chunklen = chunklen + len(r) + 1
+ if chunklen > MAX_CMDLINE:
+ recipchunks.append(string.join(currentchunk))
+ currentchunk = []
+ chunklen = 0
+ # pick up the last one
+ if chunklen:
+ recipchunks.append(string.join(currentchunk))
+ # get all the lines of the message, since we're going to do this over and
+ # over again
+ msgtext = str(msg)
+ msglen = len(msgtext)
+ # cycle through all chunks
+ failedrecips = []
+ for chunk in recipchunks:
+ # TBD: SECURITY ALERT. This invokes the shell!
+ fp = os.popen(cmd + chunk, 'w')
+ fp.write(msgtext)
+ status = fp.close()
+ if status:
+ errcode = (status & 0xff00) >> 8
+ syslog('post', 'post to %s from %s, size=%d, failure=%d',
+ mlist.internal_name(), msg.get_sender(),
+ msglen, errcode)
+ # TBD: can we do better than this? What if only one recipient out
+ # of the entire chunk failed?
+ failedrecips.append(chunk)
+ # Log the successful post
+ syslog('post', 'post to %s from %s, size=%d, success',
+ mlist.internal_name(), msg.get_sender(), msglen)
+ if failedrecips:
+ msgdata['recips'] = failedrecips
+ raise Errors.SomeRecipientsFailed
diff --git a/Mailman/Handlers/SpamDetect.py b/Mailman/Handlers/SpamDetect.py
new file mode 100644
index 00000000..7597b9e9
--- /dev/null
+++ b/Mailman/Handlers/SpamDetect.py
@@ -0,0 +1,50 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Do more detailed spam detection.
+
+This module hard codes site wide spam detection. By hacking the
+KNOWN_SPAMMERS variable, you can set up more regular expression matches
+against message headers. If spam is detected the message is discarded
+immediately.
+
+TBD: This needs to be made more configurable and robust.
+"""
+
+import re
+
+from Mailman import mm_cfg
+from Mailman import Errors
+
+
+
+class SpamDetected(Errors.DiscardMessage):
+ """The message contains known spam"""
+
+
+
+def process(mlist, msg, msgdata):
+ if msgdata.get('approved'):
+ return
+ for header, regex in mm_cfg.KNOWN_SPAMMERS:
+ cre = re.compile(regex, re.IGNORECASE)
+ value = msg[header]
+ if not value:
+ continue
+ mo = cre.search(value)
+ if mo:
+ # we've detected spam, so throw the message away
+ raise SpamDetected
diff --git a/Mailman/Handlers/Tagger.py b/Mailman/Handlers/Tagger.py
new file mode 100644
index 00000000..46a03f67
--- /dev/null
+++ b/Mailman/Handlers/Tagger.py
@@ -0,0 +1,156 @@
+# Copyright (C) 2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Extract topics from the original mail message.
+"""
+
+import re
+import email
+import email.Errors
+import email.Iterators
+import email.Parser
+
+from Mailman.Logging.Syslog import syslog
+
+CRNL = '\r\n'
+EMPTYSTRING = ''
+NLTAB = '\n\t'
+
+
+
+def process(mlist, msg, msgdata):
+ if not mlist.topics_enabled:
+ return
+ # Extract the Subject:, Keywords:, and possibly body text
+ matchlines = []
+ matchlines.append(msg.get('subject', None))
+ matchlines.append(msg.get('keywords', None))
+ if mlist.topics_bodylines_limit == 0:
+ # Don't scan any body lines
+ pass
+ elif mlist.topics_bodylines_limit < 0:
+ # Scan all body lines
+ matchlines.extend(scanbody(msg))
+ else:
+ # Scan just some of the body lines
+ matchlines.extend(scanbody(msg, mlist.topics_bodylines_limit))
+ matchlines = filter(None, matchlines)
+ # For each regular expression in the topics list, see if any of the lines
+ # of interest from the message match the regexp. If so, the message gets
+ # added to the specific topics bucket.
+ hits = {}
+ for name, pattern, desc, emptyflag in mlist.topics:
+ cre = re.compile(pattern, re.IGNORECASE | re.VERBOSE)
+ for line in matchlines:
+ if cre.search(line):
+ hits[name] = 1
+ break
+ if hits:
+ msgdata['topichits'] = hits.keys()
+ msg['X-Topics'] = NLTAB.join(hits.keys())
+
+
+
+def scanbody(msg, numlines=None):
+ # We only scan the body of the message if it is of MIME type text/plain,
+ # or if the outer type is multipart/alternative and there is a text/plain
+ # part. Anything else, and the body is ignored for header-scan purposes.
+ found = None
+ if msg.get_type('text/plain') == 'text/plain':
+ found = msg
+ elif msg.is_multipart() and msg.get_type() == 'multipart/alternative':
+ for found in msg.get_payload():
+ if found.get_type('text/plain') == 'text/plain':
+ break
+ else:
+ found = None
+ if not found:
+ return []
+ # Now that we have a Message object that meets our criteria, let's extract
+ # the first numlines of body text.
+ lines = []
+ lineno = 0
+ reader = list(email.Iterators.body_line_iterator(msg))
+ while numlines is None or lineno < numlines:
+ try:
+ line = reader.pop(0)
+ except IndexError:
+ break
+ # Blank lines don't count
+ if not line.strip():
+ continue
+ lineno += 1
+ lines.append(line)
+ # Concatenate those body text lines with newlines, and then create a new
+ # message object from those lines.
+ p = _ForgivingParser()
+ msg = p.parsestr(EMPTYSTRING.join(lines))
+ return msg.get_all('subject', []) + msg.get_all('keywords', [])
+
+
+
+class _ForgivingParser(email.Parser.HeaderParser):
+ # Be a little more forgiving about non-header/continuation lines, since
+ # we'll just read as much as we can from "header-like" lines in the body.
+ #
+ # BAW: WIBNI we didn't have to cut-n-paste this whole thing just to
+ # specialize the way it returns?
+ def _parseheaders(self, container, fp):
+ # Parse the headers, returning a list of header/value pairs. None as
+ # the header means the Unix-From header.
+ lastheader = ''
+ lastvalue = []
+ lineno = 0
+ while 1:
+ # Don't strip the line before we test for the end condition,
+ # because whitespace-only header lines are RFC compliant
+ # continuation lines.
+ line = fp.readline()
+ if not line:
+ break
+ line = line.splitlines()[0]
+ if not line:
+ break
+ # Ignore the trailing newline
+ lineno += 1
+ # Check for initial Unix From_ line
+ if line.startswith('From '):
+ if lineno == 1:
+ container.set_unixfrom(line)
+ continue
+ else:
+ break
+ # Header continuation line
+ if line[0] in ' \t':
+ if not lastheader:
+ break
+ lastvalue.append(line)
+ continue
+ # Normal, non-continuation header. BAW: this should check to make
+ # sure it's a legal header, e.g. doesn't contain spaces. Also, we
+ # should expose the header matching algorithm in the API, and
+ # allow for a non-strict parsing mode (that ignores the line
+ # instead of raising the exception).
+ i = line.find(':')
+ if i < 0:
+ break
+ if lastheader:
+ container[lastheader] = NLTAB.join(lastvalue)
+ lastheader = line[:i]
+ lastvalue = [line[i+1:].lstrip()]
+ # Make sure we retain the last header
+ if lastheader:
+ container[lastheader] = NLTAB.join(lastvalue)
diff --git a/Mailman/Handlers/ToArchive.py b/Mailman/Handlers/ToArchive.py
new file mode 100644
index 00000000..dc19f963
--- /dev/null
+++ b/Mailman/Handlers/ToArchive.py
@@ -0,0 +1,39 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Add the message to the archives."""
+
+import time
+from cStringIO import StringIO
+
+from Mailman import mm_cfg
+from Mailman.Queue.sbcache import get_switchboard
+
+
+
+def process(mlist, msg, msgdata):
+ # short circuits
+ if msgdata.get('isdigest') or not mlist.archive:
+ return
+ # Common practice seems to favor "X-No-Archive: yes". No other value for
+ # this header seems to make sense, so we'll just test for it's presence.
+ # I'm keeping "X-Archive: no" for backwards compatibility.
+ if msg.has_key('x-no-archive') or msg.get('x-archive', '').lower() == 'no':
+ return
+ # Send the message to the archiver queue
+ archq = get_switchboard(mm_cfg.ARCHQUEUE_DIR)
+ # Send the message to the queue
+ archq.enqueue(msg, msgdata)
diff --git a/Mailman/Handlers/ToDigest.py b/Mailman/Handlers/ToDigest.py
new file mode 100644
index 00000000..d735cd69
--- /dev/null
+++ b/Mailman/Handlers/ToDigest.py
@@ -0,0 +1,351 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Add the message to the list's current digest and possibly send it.
+"""
+
+# Messages are accumulated to a Unix mailbox compatible file containing all
+# the messages destined for the digest. This file must be parsable by the
+# mailbox.UnixMailbox class (i.e. it must be ^From_ quoted).
+#
+# When the file reaches the size threshold, it is moved to the qfiles/digest
+# directory and the DigestRunner will craft the MIME, rfc1153, and
+# (eventually) URL-subject linked digests from the mbox.
+
+import os
+import re
+import time
+from types import ListType
+from cStringIO import StringIO
+
+from email.Parser import Parser
+from email.Generator import Generator
+from email.MIMEBase import MIMEBase
+from email.MIMEText import MIMEText
+from email.MIMEMessage import MIMEMessage
+from email.Utils import getaddresses
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import i18n
+from Mailman.MemberAdaptor import ENABLED
+from Mailman.Handlers.Decorate import decorate
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Mailbox import Mailbox
+
+_ = i18n._
+
+
+# rfc1153 says we should keep only these headers, and present them in this
+# exact order.
+KEEP = ['Date', 'From', 'To', 'Cc', 'Subject', 'Message-ID', 'Keywords',
+ # I believe we should also keep these headers though.
+ 'In-Reply-To', 'References', 'Content-Type', 'MIME-Version',
+ 'Content-Transfer-Encoding', 'Precedence', 'Reply-To',
+ # Mailman 2.0 adds these headers, but they don't need to be kept from
+ # the original message: Message
+ ]
+
+
+
+def process(mlist, msg, msgdata):
+ # Short circuit non-digestable lists.
+ if not mlist.digestable or msgdata.get('isdigest'):
+ return
+ mboxfile = os.path.join(mlist.fullpath(), 'digest.mbox')
+ omask = os.umask(007)
+ try:
+ mboxfp = open(mboxfile, 'a+')
+ finally:
+ os.umask(omask)
+ g = Generator(mboxfp)
+ g(msg, unixfrom=1)
+ # Calculate the current size of the accumulation file. This will not tell
+ # us exactly how big the MIME, rfc1153, or any other generated digest
+ # message will be, but it's the most easily available metric to decide
+ # whether the size threshold has been reached.
+ mboxfp.flush()
+ size = os.path.getsize(mboxfile)
+ if size / 1024.0 >= mlist.digest_size_threshhold:
+ # This is a bit of a kludge to get the mbox file moved to the digest
+ # queue directory.
+ mboxfp.seek(0)
+ send_digests(mlist, mboxfp)
+ os.unlink(mboxfile)
+ mboxfp.close()
+
+
+
+def send_digests(mlist, mboxfp):
+ # Set the digest volume and time
+ if mlist.digest_last_sent_at:
+ bump = 0
+ # See if we should bump the digest volume number
+ timetup = time.localtime(mlist.digest_last_sent_at)
+ now = time.localtime(time.time())
+ freq = mlist.digest_volume_frequency
+ if freq == 0 and timetup[0] < now[0]:
+ # Yearly
+ bump = 1
+ elif freq == 1 and timetup[1] <> now[1]:
+ # Monthly, but we take a cheap way to calculate this. We assume
+ # that the clock isn't going to be reset backwards.
+ bump = 1
+ elif freq == 2 and (timetup[1] % 4 <> now[1] % 4):
+ # Quarterly, same caveat
+ bump = 1
+ elif freq == 3:
+ # Once again, take a cheap way of calculating this
+ weeknum_last = int(time.strftime('%W', timetup))
+ weeknum_now = int(time.strftime('%W', now))
+ if weeknum_now > weeknum_last or timetup[0] > now[0]:
+ bump = 1
+ elif freq == 4 and timetup[7] <> now[7]:
+ # Daily
+ bump = 1
+ if bump:
+ mlist.bump_digest_volume()
+ mlist.digest_last_sent_at = time.time()
+ # Wrapper around actually digest crafter to set up the language context
+ # properly. All digests are translated to the list's preferred language.
+ otranslation = i18n.get_translation()
+ i18n.set_language(mlist.preferred_language)
+ try:
+ send_i18n_digests(mlist, mboxfp)
+ finally:
+ i18n.set_translation(otranslation)
+
+
+
+def send_i18n_digests(mlist, mboxfp):
+ mbox = Mailbox(mboxfp)
+ # Prepare common information
+ lang = mlist.preferred_language
+ realname = mlist.real_name
+ volume = mlist.volume
+ issue = mlist.next_digest_number
+ digestid = _('%(realname)s Digest, Vol %(volume)d, Issue %(issue)d')
+ # Set things up for the MIME digest. Only headers not added by
+ # CookHeaders need be added here.
+ mimemsg = Message.Message()
+ mimemsg['Content-Type'] = 'multipart/mixed'
+ mimemsg['MIME-Version'] = '1.0'
+ mimemsg['From'] = mlist.GetRequestEmail()
+ mimemsg['Subject'] = digestid
+ mimemsg['To'] = mlist.GetListEmail()
+ mimemsg['Reply-To'] = mlist.GetListEmail()
+ # Set things up for the rfc1153 digest
+ plainmsg = StringIO()
+ rfc1153msg = Message.Message()
+ rfc1153msg['From'] = mlist.GetRequestEmail()
+ rfc1153msg['Subject'] = digestid
+ rfc1153msg['To'] = mlist.GetListEmail()
+ rfc1153msg['Reply-To'] = mlist.GetListEmail()
+ separator70 = '-' * 70
+ separator30 = '-' * 30
+ # In the rfc1153 digest, the masthead contains the digest boilerplate plus
+ # any digest header. In the MIME digests, the masthead and digest header
+ # are separate MIME subobjects. In either case, it's the first thing in
+ # the digest, and we can calculate it now, so go ahead and add it now.
+ mastheadtxt = Utils.maketext(
+ 'masthead.txt',
+ {'real_name' : mlist.real_name,
+ 'got_list_email': mlist.GetListEmail(),
+ 'got_listinfo_url': mlist.GetScriptURL('listinfo', absolute=1),
+ 'got_request_email': mlist.GetRequestEmail(),
+ 'got_owner_email': mlist.GetOwnerEmail(),
+ }, mlist=mlist)
+ # MIME
+ masthead = MIMEText(mastheadtxt, _charset=Utils.GetCharSet(lang))
+ masthead['Content-Description'] = digestid
+ mimemsg.attach(masthead)
+ # rfc1153
+ print >> plainmsg, mastheadtxt
+ print >> plainmsg
+ # Now add the optional digest header
+ if mlist.digest_header:
+ headertxt = decorate(mlist, mlist.digest_header, _('digest header'))
+ # MIME
+ header = MIMEText(headertxt)
+ header['Content-Description'] = _('Digest Header')
+ mimemsg.attach(header)
+ # rfc1153
+ print >> plainmsg, headertxt
+ print >> plainmsg
+ # Now we have to cruise through all the messages accumulated in the
+ # mailbox file. We can't add these messages to the plainmsg and mimemsg
+ # yet, because we first have to calculate the table of contents
+ # (i.e. grok out all the Subjects). Store the messages in a list until
+ # we're ready for them.
+ #
+ # Meanwhile prepare things for the table of contents
+ toc = StringIO()
+ print >> toc, _("Today's Topics:\n")
+ # Now cruise through all the messages in the mailbox of digest messages,
+ # building the MIME payload and core of the rfc1153 digest. We'll also
+ # accumulate Subject: headers and authors for the table-of-contents.
+ messages = []
+ msgcount = 0
+ msg = mbox.next()
+ while msg is not None:
+ if msg == '':
+ # It was an unparseable message
+ msg = mbox.next()
+ msgcount += 1
+ messages.append(msg)
+ # Get the Subject header
+ subject = msg.get('subject', _('(no subject)'))
+ # Don't include the redundant subject prefix in the toc
+ mo = re.match('(re:? *)?(%s)' % re.escape(mlist.subject_prefix),
+ subject, re.IGNORECASE)
+ if mo:
+ subject = subject[:mo.start(2)] + subject[mo.end(2):]
+ addresses = getaddresses([msg.get('From', '')])
+ username = ''
+ # Take only the first author we find
+ if type(addresses) is ListType and len(addresses) > 0:
+ username = addresses[0][0]
+ if username:
+ username = ' (%s)' % username
+ # Wrap the toc subject line
+ wrapped = Utils.wrap('%2d. %s' % (msgcount, subject))
+ # Split by lines and see if the username can fit on the last line
+ slines = wrapped.split('\n')
+ if len(slines[-1]) + len(username) > 70:
+ slines.append(username)
+ else:
+ slines[-1] += username
+ # Add this subject to the accumulating topics
+ first = 1
+ for line in slines:
+ if first:
+ print >> toc, ' ', line
+ first = 0
+ else:
+ print >> toc, ' ', line
+ # We do not want all the headers of the original message to leak
+ # through in the digest messages. For simplicity, we'll leave the
+ # same set of headers in both digests, i.e. those required in rfc1153
+ # plus a couple of other useful ones. We also need to reorder the
+ # headers according to rfc1153.
+ keeper = {}
+ for keep in KEEP:
+ keeper[keep] = msg.get_all(keep, [])
+ # Now remove all unkempt headers :)
+ for header in msg.keys():
+ del msg[header]
+ # And add back the kept header in the rfc1153 designated order
+ for keep in KEEP:
+ for field in keeper[keep]:
+ msg[keep] = field
+ # And a bit of extra stuff
+ msg['Message'] = `msgcount`
+ # Get the next message in the digest mailbox
+ msg = mbox.next()
+ # Now we're finished with all the messages in the digest. First do some
+ # sanity checking and then on to adding the toc.
+ if msgcount == 0:
+ # Why did we even get here?
+ return
+ toctext = toc.getvalue()
+ # MIME
+ tocpart = MIMEText(toctext)
+ tocpart['Content-Description']= _("Today's Topics (%(msgcount)d messages)")
+ mimemsg.attach(tocpart)
+ # rfc1153
+ print >> plainmsg, toctext
+ print >> plainmsg
+ # For rfc1153 digests, we now need the standard separator
+ print >> plainmsg, separator70
+ print >> plainmsg
+ # Now go through and add each message
+ mimedigest = MIMEBase('multipart', 'digest')
+ mimemsg.attach(mimedigest)
+ first = 1
+ for msg in messages:
+ # MIME
+ mimedigest.attach(MIMEMessage(msg))
+ # rfc1153
+ if first:
+ first = 0
+ else:
+ print >> plainmsg, separator30
+ print >> plainmsg
+ g = Generator(plainmsg)
+ g(msg, unixfrom=0)
+ # Now add the footer
+ if mlist.digest_footer:
+ footertxt = decorate(mlist, mlist.digest_footer, _('digest footer'))
+ # MIME
+ footer = MIMEText(footertxt)
+ footer['Content-Description'] = _('Digest Footer')
+ mimemsg.attach(footer)
+ # rfc1153
+ # BAW: This is not strictly conformant rfc1153. The trailer is only
+ # supposed to contain two lines, i.e. the "End of ... Digest" line and
+ # the row of asterisks. If this screws up MUAs, the solution is to
+ # add the footer as the last message in the rfc1153 digest. I just
+ # hate the way that VM does that and I think it's confusing to users,
+ # so don't do it unless there's a clamor.
+ print >> plainmsg, separator30
+ print >> plainmsg
+ print >> plainmsg, footertxt
+ print >> plainmsg
+ # Do the last bit of stuff for each digest type
+ signoff = _('End of ') + digestid
+ # MIME
+ # BAW: This stuff is outside the normal MIME goo, and it's what the old
+ # MIME digester did. No one seemed to complain, probably because you
+ # won't see it in an MUA that can't display the raw message. We've never
+ # got complaints before, but if we do, just wax this. It's primarily
+ # included for (marginally useful) backwards compatibility.
+ mimemsg.postamble = signoff
+ # rfc1153
+ print >> plainmsg, signoff
+ print >> plainmsg, '*' * len(signoff)
+ # Do our final bit of housekeeping, and then send each message to the
+ # outgoing queue for delivery.
+ mlist.next_digest_number += 1
+ virginq = get_switchboard(mm_cfg.VIRGINQUEUE_DIR)
+ # Calculate the recipients lists
+ plainrecips = []
+ mimerecips = []
+ drecips = mlist.getDigestMemberKeys() + mlist.one_last_digest.keys()
+ for user in mlist.getMemberCPAddresses(drecips):
+ # user might be None if someone who toggled off digest delivery
+ # subsequently unsubscribed from the mailing list. Also, filter out
+ # folks who have disabled delivery.
+ if user is None or mlist.getDeliveryStatus(user) <> ENABLED:
+ continue
+ # Otherwise, decide whether they get MIME or RFC 1153 digests
+ if mlist.getMemberOption(user, mm_cfg.DisableMime):
+ plainrecips.append(user)
+ else:
+ mimerecips.append(user)
+ # Zap this since we're now delivering the last digest to these folks.
+ mlist.one_last_digest.clear()
+ # MIME
+ virginq.enqueue(mimemsg,
+ recips=mimerecips,
+ listname=mlist.internal_name(),
+ isdigest=1)
+ # rfc1153
+ rfc1153msg.set_payload(plainmsg.getvalue())
+ virginq.enqueue(rfc1153msg,
+ recips=plainrecips,
+ listname=mlist.internal_name(),
+ isdigest=1)
diff --git a/Mailman/Handlers/ToOutgoing.py b/Mailman/Handlers/ToOutgoing.py
new file mode 100644
index 00000000..4732b984
--- /dev/null
+++ b/Mailman/Handlers/ToOutgoing.py
@@ -0,0 +1,55 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Re-queue the message to the outgoing queue.
+
+This module is only for use by the IncomingRunner for delivering messages
+posted to the list membership. Anything else that needs to go out to some
+recipient should just be placed in the out queue directly.
+"""
+
+from Mailman import mm_cfg
+from Mailman.Queue.sbcache import get_switchboard
+
+
+
+def process(mlist, msg, msgdata):
+ interval = mm_cfg.VERP_DELIVERY_INTERVAL
+ # Should we VERP this message? If personalization is enabled for this
+ # list and VERP_PERSONALIZED_DELIVERIES is true, then yes we VERP it.
+ # Also, if personalization is /not/ enabled, but VERP_DELIVERY_INTERVAL is
+ # set (and we've hit this interval), then again, this message should be
+ # VERPed. Otherwise, no.
+ #
+ # Note that the verp flag may already be set, e.g. by mailpasswds using
+ # VERP_PASSWORD_REMINDERS. Preserve any existing verp flag.
+ if msgdata.has_key('verp'):
+ pass
+ elif mlist.personalize:
+ if mm_cfg.VERP_PERSONALIZED_DELIVERIES:
+ msgdata['verp'] = 1
+ elif interval == 0:
+ # Never VERP
+ pass
+ elif interval == 1:
+ # VERP every time
+ msgdata['verp'] = 1
+ else:
+ # VERP every `inteval' number of times
+ msgdata['verp'] = not int(mlist.post_id) % interval
+ # And now drop the message in qfiles/out
+ outq = get_switchboard(mm_cfg.OUTQUEUE_DIR)
+ outq.enqueue(msg, msgdata, listname=mlist.internal_name())
diff --git a/Mailman/Handlers/ToUsenet.py b/Mailman/Handlers/ToUsenet.py
new file mode 100644
index 00000000..2d6755b7
--- /dev/null
+++ b/Mailman/Handlers/ToUsenet.py
@@ -0,0 +1,44 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Move the message to the mail->news queue."""
+
+from Mailman import mm_cfg
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Logging.Syslog import syslog
+
+COMMASPACE = ', '
+
+
+def process(mlist, msg, msgdata):
+ # short circuits
+ if not mlist.gateway_to_news or \
+ msgdata.get('isdigest') or \
+ msgdata.get('fromusenet'):
+ return
+ # sanity checks
+ error = []
+ if not mlist.linked_newsgroup:
+ error.append('no newsgroup')
+ if not mlist.nntp_host:
+ error.append('no NNTP host')
+ if error:
+ syslog('error', 'NNTP gateway improperly configured: %s',
+ COMMASPACE.join(error))
+ return
+ # Put the message in the news runner's queue
+ newsq = get_switchboard(mm_cfg.NEWSQUEUE_DIR)
+ newsq.enqueue(msg, msgdata, listname=mlist.internal_name())
diff --git a/Mailman/Handlers/__init__.py b/Mailman/Handlers/__init__.py
new file mode 100644
index 00000000..2cbbabb1
--- /dev/null
+++ b/Mailman/Handlers/__init__.py
@@ -0,0 +1,15 @@
+# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.