# 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. """Process mailing list user commands arriving via email.""" # Try to stay close to majordomo commands, but accept common mistakes. # Not implemented: get / index / which. import os import sys import re import traceback import email.Iterators from cStringIO import StringIO from Mailman import mm_cfg from Mailman import Utils from Mailman import Errors from Mailman import Message from Mailman import Pending from Mailman import MemberAdaptor from Mailman.UserDesc import UserDesc from Mailman.Logging.Syslog import syslog import Mailman.i18n MAXERRORS = 5 MAXCOLUMN = 70 NL = '\n' SPACE = ' ' # jcrey: I must to do a trick to make pygettext detect this strings. First we # define a fake function. Idea taken from Mads. def _(s): return s # Command descriptions HIDE = _('''When turned on, your email address is concealed on the Web page that lists the members of the mailing list.''') NOMAIL = _('''When turned on, delivery to your email address is disabled, but your address is still subscribed. This is useful if you plan on taking a short vacation.''') ACK = _('''When turned on, you get a separate acknowledgement email when you post messages to the list.''') NOTMETOO = _('''When turned on, you do *not* get copies of your own posts to the list. Otherwise, you do get copies of your own posts (yes, this seems a little backwards). This does not affect the contents of digests, so if you receive postings in digests, you will always get copies of your messages in the digest.''') DIGEST = _('''When turned on, you get postings from the list bundled into digests. Otherwise, you get each individual message immediately as it is posted to the list.''') PLAIN = _("""When turned on, you get `plain' digests, which are actually formatted using the RFC1154 digest format. This format can be easier to read if you have a non-MIME compliant mail reader. When this option is turned off, you get digests in MIME format, which are much better if you have a mail reader that supports MIME.""") option_desc = {'hide' : HIDE, 'nomail' : NOMAIL, 'ack' : ACK, 'notmetoo': NOTMETOO, 'digest' : DIGEST, 'plain' : PLAIN, } # jcrey: and then the real one _ = Mailman.i18n._ option_info = {'hide' : mm_cfg.ConcealSubscription, 'nomail' : mm_cfg.DisableDelivery, 'ack' : mm_cfg.AcknowledgePosts, 'notmetoo': mm_cfg.DontReceiveOwnPosts, 'digest' : 0, 'plain' : mm_cfg.DisableMime, } # ordered list options = ('hide', 'nomail', 'ack', 'notmetoo', 'digest', 'plain') # strip just the outer layer of quotes quotecre = re.compile(r'["\'`](?P.*)["\'`]') class MailCommandHandler: def __init__(self): self.__errors = 0 self.__respbuf = '' self.__dispatch = None self.__noresponse = 0 def __makedispatch(self): self.__dispatch = { 'subscribe' : self.ProcessSubscribeCmd, 'join' : self.ProcessSubscribeCmd, 'confirm' : self.ProcessConfirmCmd, 'unsubscribe' : self.ProcessUnsubscribeCmd, 'remove' : self.ProcessUnsubscribeCmd, 'leave' : self.ProcessUnsubscribeCmd, 'who' : self.ProcessWhoCmd, 'info' : self.ProcessInfoCmd, 'lists' : self.ProcessListsCmd, 'help' : self.ProcessHelpCmd, 'set' : self.ProcessSetCmd, 'options' : self.ProcessOptionsCmd, 'password' : self.ProcessPasswordCmd, } def AddToResponse(self, text, trunc=MAXCOLUMN, prefix=''): # Strip final newline if text and text[-1] == '\n': text = text[:-1] for line in text.split('\n'): line = prefix + line if trunc and len(line) > trunc: line = line[:trunc-3] + '...' self.__respbuf += line + '\n' def AddError(self, text, prefix='>>>>> ', trunc=MAXCOLUMN): self.__errors += 1 self.AddToResponse(text, trunc=trunc, prefix=prefix) def ParseMailCommands(self, msg, msgdata): if self.__dispatch is None: self.__makedispatch() self.__noresponse = 0 # Break any infloops. If this has come from a Mailman server then # it'll have this header. It's still possible to infloop between two # servers because there's no guaranteed way to know it came from a # bot. if msg['x-beenthere'] or msg['list-id']: return # Check the autoresponse stuff if self.autorespond_requests: # BAW: this is a hack and is not safe with respect to errors in # the Replybot module. It should be redesigned to work with the # robust delivery scheme. from Mailman.Handlers import Replybot # BAW: Replybot doesn't currently support multiple languages Replybot.process(self, msg, msgdata={'torequest':1}) if self.autorespond_requests == 1: # Yes, auto-respond and discard return subject = msg.get('subject', '') sender = msg.get_sender().lower().split('@')[0] # # XXX: why 'orphanage'? if sender in mm_cfg.LIKELY_BOUNCE_SENDERS: # This is for what are probably delivery-failure notices of # subscription confirmations that are, of necessity, bounced # back to the -request address. syslog('bounce', '''\ %s: Mailcmd rejected Reason: Probable bounced subscribe-confirmation From: %s Subject: %s''', self.internal_name(), msg['from'], subject) return if subject: subject = subject.strip() # remove quotes so "help" works mo = quotecre.search(subject) if mo: subject = mo.group('cmd') # We must have a real list because of the way we insert an element and # iterate over it. In email 0.x (pre-Python 2.2) body_line_iterator() # returns a real list, but in email 1.x (Python 2.2), it returns a # generator. This is the only way to coerce it to a concrete list # object in both cases. lines = list(email.Iterators.body_line_iterator(msg)) # Find out if the subject line has a command on it subjcmd = [] if subject: cmdfound = 0 for word in subject.split(): word = word.lower() if cmdfound: subjcmd.append(word) elif self.__dispatch.has_key(word): cmdfound = 1 subjcmd.append(word) if subjcmd: lines.insert(0, SPACE.join(subjcmd)) else: self.AddError(_('Subject line ignored:\n ') + subject) processed = {} # For avoiding redundancies. maxlines = mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES for linecount in range(len(lines)): if linecount > maxlines: self.AddError(_( "Maximum command lines (%(maxlines)d) encountered, ignoring the rest...")) for line in lines[linecount:]: self.AddToResponse("> " + line, trunc=0) break line = lines[linecount].strip() if not line: continue args = line.split() cmd = args[0].lower() # remove quotes so "help" or `help' works mo = quotecre.search(cmd) if mo: cmd = mo.group('cmd') args = args[1:] if cmd in ['end', '--']: self.AddToResponse('\n***** ' + _('End: ') + line + '\n' + _('The rest of the message is ignored:')) for line in lines[linecount+1:]: self.AddToResponse(line, trunc=0, prefix='> ') break if not self.__dispatch.has_key(cmd): self.AddError(line, prefix=_('Command? ')) if self.__errors >= MAXERRORS: self.AddError(_( '\nToo many errors encountered; the rest of the message is ignored:')) for line in lines[linecount+1:]: self.AddToResponse(line, trunc=0, prefix='> ') break else: # We do not repeat identical commands. (Eg, it's common # with other mlm's for people to put a command in the # subject and the body, uncertain which one has effect...) isdup = 0 if not processed.has_key(cmd): processed[cmd] = [] else: for did in processed[cmd]: if args == did: isdup = 1 break if not isdup: processed[cmd].append(args) self.AddToResponse('\n***** ' + line) try: # note that all expected exceptions must be handled by # the dispatch function. We'll globally collect any # unexpected (e.g. uncaught) exceptions here. Such an # exception stops parsing of email commands # immediately self.__dispatch[cmd](args, line, msg) except: admin = self.GetAdminEmail() sfp = StringIO() traceback.print_exc(file=sfp) tbmsg = sfp.getvalue() errmsg = Utils.wrap(_('''\ An unexpected Mailman error has occurred. Please forward your request to the human list administrator in charge of this list at <%(admin)s>. The traceback is attached below and will be forwarded to the list administrator automatically.''')) self.AddError(errmsg, trunc=0) self.AddToResponse('\n' + tbmsg, trunc=0) # log it to the error file syslog('error', 'Unexpected Mailman error:\n%s', tbmsg) # and send the traceback to the user lang = msgdata.get('lang', self.getMemberLanguage(admin)) responsemsg = Message.UserNotification( admin, admin, _('Unexpected Mailman error'), _('''\ An unexpected Mailman error has occurred in MailCommandHandler.ParseMailCommands(). Here is the traceback: ''') + tbmsg, lang=lang) responsemsg['X-No-Archive'] = 'yes' responsemsg.send(self) break # send the response if not self.__noresponse: adminaddr = self.GetAdminEmail() requestaddr = self.GetRequestEmail() if self.__errors > 0: header = Utils.wrap(_('''This is an automated response. There were problems with the email commands you sent to Mailman via the administrative address %(requestaddr)s. To obtain instructions on valid Mailman email commands, send email to %(requestaddr)s with the word "help" in the subject line or in the body of the message. If you want to reach the human being that manages this mailing list, please send your message to %(adminaddr)s. The following is a detailed description of the problems. ''')) self.__respbuf = header + self.__respbuf # send the response realname = self.real_name subject = _('Mailman results for %(realname)s') sender = msg.get_sender() lang = msgdata.get('lang', self.getMemberLanguage(sender)) responsemsg = Message.UserNotification(msg.get_sender(), self.GetRequestEmail(), subject, self.__respbuf, lang) responsemsg.send(self) self.__respbuf = '' self.__errors = 0 def ProcessPasswordCmd(self, args, cmd, mail): if len(args) <> 0 and len(args) <> 2: self.AddError(_("Usage: password [ ]")) return sender = mail.get_sender() if not args: # Mail user's password to user try: password = self.getMemberPassword(sender) except Errors.NotAMember: password = None if self.isMember(sender) and password: user = self.getMemberCPAddress(sender) self.AddToResponse(_( 'You are subscribed as %(user)s, with password: %(password)s'), trunc=0) else: self.AddError(_('Found no password for %(sender)s'), trunc=0) return # Try to change password try: oldpw = self.getMemberPassword(sender) if oldpw <> args[0]: self.AddError(_('You gave the wrong password.')) else: self.setMemberPassword(sender, args[1]) self.AddToResponse(_('Succeeded.')) except Errors.NotAMemberError: self.AddError(_("%(sender)s is not a member of this list."), trunc=0) def ProcessOptionsCmd(self, args, cmd, mail): sender = mail.get_sender() if not self.isMember(sender): self.AddError(_("%(sender)s is not a member of this list."), trunc=0) return for option in options: # We handle disabled delivery differently now if option == 'nomail': status = self.getDeliveryStatus(sender) if status == MemberAdaptor.ENABLED: value = _('off') else: reason = { MemberAdaptor.BYUSER: _('by your configuration'), MemberAdaptor.BYADMIN: _('by list admin'), MemberAdaptor.BYBOUNCE: _('by bounce'), MemberAdaptor.UNKNOWN : _('for unknown reasons'), }[status] value = _('on (%(reason)s') elif self.getMemberOption(sender, option_info[option]): value = 'on' else: value = 'off' self.AddToResponse('%8s: %s' % (option, value)) self.AddToResponse(_("""\ To change an option, do: set