diff options
Diffstat (limited to 'Mailman/Handlers')
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(' ', ' ').replace('\t', ' '*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. |