Code

*** empty log message ***
[roundup.git] / roundup / mailgw.py
index 23e294315d474aeb23fd3760766311606f6f813c..c76706c4f586414e459e1aa23fcb0a0720e77b0b 100644 (file)
@@ -73,7 +73,7 @@ are calling the create() method to create a new node). If an auditor raises
 an exception, the original message is bounced back to the sender with the
 explanatory message given in the exception. 
 
-$Id: mailgw.py,v 1.132 2003-09-08 21:27:16 jlgijsbers Exp $
+$Id: mailgw.py,v 1.141 2004-01-17 13:49:06 jlgijsbers Exp $
 """
 
 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
@@ -92,14 +92,22 @@ class MailUsageError(ValueError):
     pass
 
 class MailUsageHelp(Exception):
-    pass
-
-class MailLoop(Exception):
-    """ We've seen this message before... """
+    """ We need to send the help message to the user. """
     pass
 
 class Unauthorized(Exception):
     """ Access denied """
+    pass
+
+class IgnoreMessage(Exception):
+    """ A general class of message that we should ignore. """
+    pass
+class IgnoreBulk(IgnoreMessage):
+        """ This is email from a mailing list or from a vacation program. """
+        pass
+class IgnoreLoop(IgnoreMessage):
+        """ We've seen this message before... """
+        pass
 
 def initialiseSecurity(security):
     ''' Create some Permissions and Roles on the security object
@@ -137,7 +145,7 @@ class Message(mimetools.Message):
     ''' subclass mimetools.Message so we can retrieve the parts of the
         message...
     '''
-    def getPart(self):
+    def getpart(self):
         ''' Get a single part of a multipart message and return it as a new
             Message instance.
         '''
@@ -156,12 +164,136 @@ class Message(mimetools.Message):
         s.seek(0)
         return Message(s)
 
+    def getparts(self):
+        """Get all parts of this multipart message."""
+        # skip over the intro to the first boundary
+        self.getpart()
+
+        # accumulate the other parts
+        parts = []
+        while 1:
+            part = self.getpart()
+            if part is None:
+                break
+            parts.append(part)
+        return parts
+
     def getheader(self, name, default=None):
         hdr = mimetools.Message.getheader(self, name, default)
         if hdr:
             hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
         return rfc2822.decode_header(hdr)
+
+    def getname(self):
+        """Find an appropriate name for this message."""
+        if self.gettype() == 'message/rfc822':
+            # handle message/rfc822 specially - the name should be
+            # the subject of the actual e-mail embedded here
+            self.fp.seek(0)
+            name = Message(self.fp).getheader('subject')
+        else:
+            # try name on Content-Type
+            name = self.getparam('name')
+            if not name:
+                disp = self.getheader('content-disposition', None)
+                if disp:
+                    name = getparam(disp, 'filename')
+
+        if name:
+            return name.strip()
+
+    def getbody(self):
+        """Get the decoded message body."""
+        self.rewindbody()
+        encoding = self.getencoding()
+        data = None
+        if encoding == 'base64':
+            # BUG: is base64 really used for text encoding or
+            # are we inserting zip files here. 
+            data = binascii.a2b_base64(self.fp.read())
+        elif encoding == 'quoted-printable':
+            # the quopri module wants to work with files
+            decoded = cStringIO.StringIO()
+            quopri.decode(self.fp, decoded)
+            data = decoded.getvalue()
+        elif encoding == 'uuencoded':
+            data = binascii.a2b_uu(self.fp.read())
+        else:
+            # take it as text
+            data = self.fp.read()
+        
+        # Encode message to unicode
+        charset = rfc2822.unaliasCharset(self.getparam("charset"))
+        if charset:
+            # Do conversion only if charset specified
+            edata = unicode(data, charset).encode('utf-8')
+            # Convert from dos eol to unix
+            edata = edata.replace('\r\n', '\n')
+        else:
+            # Leave message content as is
+            edata = data
+                
+        return edata
+
+    # General multipart handling:
+    #   Take the first text/plain part, anything else is considered an 
+    #   attachment.
+    # multipart/mixed: multiple "unrelated" parts.
+    # multipart/signed (rfc 1847): 
+    #   The control information is carried in the second of the two 
+    #   required body parts.
+    #   ACTION: Default, so if content is text/plain we get it.
+    # multipart/encrypted (rfc 1847): 
+    #   The control information is carried in the first of the two 
+    #   required body parts.
+    #   ACTION: Not handleable as the content is encrypted.
+    # multipart/related (rfc 1872, 2112, 2387):
+    #   The Multipart/Related content-type addresses the MIME
+    #   representation of compound objects.
+    #   ACTION: Default. If we are lucky there is a text/plain.
+    #   TODO: One should use the start part and look for an Alternative
+    #   that is text/plain.
+    # multipart/Alternative (rfc 1872, 1892):
+    #   only in "related" ?
+    # multipart/report (rfc 1892):
+    #   e.g. mail system delivery status reports.
+    #   ACTION: Default. Could be ignored or used for Delivery Notification 
+    #   flagging.
+    # multipart/form-data:
+    #   For web forms only.
+
+    def extract_content(self, parent_type=None):
+        """Extract the body and the attachments recursively."""
+        content_type = self.gettype()
+        content = None
+        attachments = []
+        
+        if content_type == 'text/plain':
+            content = self.getbody()
+        elif content_type[:10] == 'multipart/':
+            for part in self.getparts():
+                new_content, new_attach = part.extract_content(content_type)
+
+                # If we haven't found a text/plain part yet, take this one,
+                # otherwise make it an attachment.
+                if not content:
+                    content = new_content
+                elif new_content:
+                    attachments.append(part.as_attachment())
+                    
+                attachments.extend(new_attach)
+        elif (parent_type == 'multipart/signed' and
+              content_type == 'application/pgp-signature'):
+            # ignore it so it won't be saved as an attachment
+            pass
+        else:
+            attachments.append(self.as_attachment())
+        return content, attachments
+
+    def as_attachment(self):
+        """Return this message as an attachment."""
+        return (self.getname(), self.gettype(), self.getbody())
+
 class MailGW:
 
     # Matches subjects like:
@@ -297,54 +429,10 @@ class MailGW:
         """
         # in some rare cases, a particularly stuffed-up e-mail will make
         # its way into here... try to handle it gracefully
-        sendto = message.getaddrlist('from')
-        if sendto:
-            if not self.trapExceptions:
-                return self.handle_message(message)
-            try:
-                return self.handle_message(message)
-            except MailUsageHelp:
-                # bounce the message back to the sender with the usage message
-                fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
-                sendto = [sendto[0][1]]
-                m = ['']
-                m.append('\n\nMail Gateway Help\n=================')
-                m.append(fulldoc)
-                self.mailer.bounce_message(message, sendto, m,
-                    subject="Mail Gateway Help")
-            except MailUsageError, value:
-                # bounce the message back to the sender with the usage message
-                fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
-                sendto = [sendto[0][1]]
-                m = ['']
-                m.append(str(value))
-                m.append('\n\nMail Gateway Help\n=================')
-                m.append(fulldoc)
-                self.mailer.bounce_message(message, sendto, m)
-            except Unauthorized, value:
-                # just inform the user that he is not authorized
-                sendto = [sendto[0][1]]
-                m = ['']
-                m.append(str(value))
-                self.mailer.bounce_message(message, sendto, m)
-            except MailLoop:
-                # XXX we should use a log file here...
-                return
-            except:
-                # bounce the message back to the sender with the error message
-                # XXX we should use a log file here...
-                sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
-                m = ['']
-                m.append('An unexpected error occurred during the processing')
-                m.append('of your message. The tracker administrator is being')
-                m.append('notified.\n')
-                m.append('----  traceback of failure  ----')
-                s = cStringIO.StringIO()
-                import traceback
-                traceback.print_exc(None, s)
-                m.append(s.getvalue())
-                self.mailer.bounce_message(message, sendto, m)
-        else:
+        sendto = message.getaddrlist('resent-from')
+        if not sendto:
+            sendto = message.getaddrlist('from')
+        if not sendto:
             # very bad-looking message - we don't even know who sent it
             # XXX we should use a log file here...
             sendto = [self.instance.config.ADMIN_EMAIL]
@@ -356,37 +444,56 @@ class MailGW:
             m.append('')
             self.mailer.bounce_message(message, sendto, m,
                 subject='Badly formed message from mail gateway')
+            return
 
-    def get_part_data_decoded(self,part):
-        encoding = part.getencoding()
-        data = None
-        if encoding == 'base64':
-            # BUG: is base64 really used for text encoding or
-            # are we inserting zip files here. 
-            data = binascii.a2b_base64(part.fp.read())
-        elif encoding == 'quoted-printable':
-            # the quopri module wants to work with files
-            decoded = cStringIO.StringIO()
-            quopri.decode(part.fp, decoded)
-            data = decoded.getvalue()
-        elif encoding == 'uuencoded':
-            data = binascii.a2b_uu(part.fp.read())
-        else:
-            # take it as text
-            data = part.fp.read()
-        
-        # Encode message to unicode
-        charset = rfc2822.unaliasCharset(part.getparam("charset"))
-        if charset:
-            # Do conversion only if charset specified
-            edata = unicode(data, charset).encode('utf-8')
-            # Convert from dos eol to unix
-            edata = edata.replace('\r\n', '\n')
-        else:
-            # Leave message content as is
-            edata = data
-                
-        return edata
+        # try normal message-handling
+        if not self.trapExceptions:
+            return self.handle_message(message)
+        try:
+            return self.handle_message(message)
+        except MailUsageHelp:
+            # bounce the message back to the sender with the usage message
+            fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
+            sendto = [sendto[0][1]]
+            m = ['']
+            m.append('\n\nMail Gateway Help\n=================')
+            m.append(fulldoc)
+            self.mailer.bounce_message(message, sendto, m,
+                subject="Mail Gateway Help")
+        except MailUsageError, value:
+            # bounce the message back to the sender with the usage message
+            fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
+            sendto = [sendto[0][1]]
+            m = ['']
+            m.append(str(value))
+            m.append('\n\nMail Gateway Help\n=================')
+            m.append(fulldoc)
+            self.mailer.bounce_message(message, sendto, m)
+        except Unauthorized, value:
+            # just inform the user that he is not authorized
+            sendto = [sendto[0][1]]
+            m = ['']
+            m.append(str(value))
+            self.mailer.bounce_message(message, sendto, m)
+        except IgnoreMessage:
+            # XXX we should use a log file here...
+            # do not take any action
+            # this exception is thrown when email should be ignored
+            return
+        except:
+            # bounce the message back to the sender with the error message
+            # XXX we should use a log file here...
+            sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
+            m = ['']
+            m.append('An unexpected error occurred during the processing')
+            m.append('of your message. The tracker administrator is being')
+            m.append('notified.\n')
+            m.append('----  traceback of failure  ----')
+            s = cStringIO.StringIO()
+            import traceback
+            traceback.print_exc(None, s)
+            m.append(s.getvalue())
+            self.mailer.bounce_message(message, sendto, m)
 
     def handle_message(self, message):
         ''' message - a Message instance
@@ -395,7 +502,11 @@ class MailGW:
         '''
         # detect loops
         if message.getheader('x-roundup-loop', ''):
-            raise MailLoop
+            raise IgnoreLoop
+
+        # detect Precedence: Bulk
+        if (message.getheader('precedence', '') == 'bulk'):
+            raise IgnoreBulk
 
         # XXX Don't enable. This doesn't work yet.
 #  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
@@ -414,6 +525,11 @@ class MailGW:
 #                    nodeid = issue.group('nodeid')
 #                    break
 
+        # determine the sender's address
+        from_list = message.getaddrlist('resent-from')
+        if not from_list:
+            from_list = message.getaddrlist('from')
+
         # handle the subject line
         subject = message.getheader('subject', '')
 
@@ -440,7 +556,7 @@ Emails to Roundup trackers must include a Subject: line!
                     self.db.confirm_registration(otk.group('otk'))
                     subject = 'Your registration to %s is complete' % \
                               self.instance.config.TRACKER_NAME
-                    sendto = message.getaddrlist('from')[0][1]
+                    sendto = [from_list[0][1]]
                     self.mailer.standard_message(sendto, subject, '') 
                     return
                 elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
@@ -574,8 +690,7 @@ The mail gateway is not properly set up. Please contact
 
         # ok, now figure out who the author is - create a new user if the
         # "create" flag is true
-        author = uidFromAddress(self.db, message.getaddrlist('from')[0],
-            create=create)
+        author = uidFromAddress(self.db, from_list[0], create=create)
 
         # if we're not recognised, and we don't get added as a user, then we
         # must be anonymous
@@ -590,7 +705,7 @@ The mail gateway is not properly set up. Please contact
 You are not a registered user.
 
 Unknown address: %s
-'''%message.getaddrlist('from')[0][1]
+'''%from_list[0][1]
             else:
                 # we're registered and we're _still_ not allowed access
                 raise Unauthorized, 'You are not permitted to access '\
@@ -639,7 +754,7 @@ Unknown address: %s
             errors, props = setPropArrayFromString(self, cl, args, nodeid)
             # handle any errors parsing the argument list
             if errors:
-                errors = '\n- '.join(errors)
+                errors = '\n- '.join(map(str, errors))
                 raise MailUsageError, '''
 There were problems handling your subject line argument list:
 - %s
@@ -662,118 +777,13 @@ Subject was: "%s"
             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
                 classname, nodeid, self.instance.config.MAIL_DOMAIN)
 
-        #
         # now handle the body - find the message
-        #
-        content_type =  message.gettype()
-        attachments = []
-        # General multipart handling:
-        #   Take the first text/plain part, anything else is considered an 
-        #   attachment.
-        # multipart/mixed: multiple "unrelated" parts.
-        # multipart/signed (rfc 1847): 
-        #   The control information is carried in the second of the two 
-        #   required body parts.
-        #   ACTION: Default, so if content is text/plain we get it.
-        # multipart/encrypted (rfc 1847): 
-        #   The control information is carried in the first of the two 
-        #   required body parts.
-        #   ACTION: Not handleable as the content is encrypted.
-        # multipart/related (rfc 1872, 2112, 2387):
-        #   The Multipart/Related content-type addresses the MIME
-        #   representation of compound objects.
-        #   ACTION: Default. If we are lucky there is a text/plain.
-        #   TODO: One should use the start part and look for an Alternative
-        #   that is text/plain.
-        # multipart/Alternative (rfc 1872, 1892):
-        #   only in "related" ?
-        # multipart/report (rfc 1892):
-        #   e.g. mail system delivery status reports.
-        #   ACTION: Default. Could be ignored or used for Delivery Notification 
-        #   flagging.
-        # multipart/form-data:
-        #   For web forms only.
-        if content_type == 'multipart/mixed':
-            # skip over the intro to the first boundary
-            part = message.getPart()
-            content = None
-            while 1:
-                # get the next part
-                part = message.getPart()
-                if part is None:
-                    break
-                # parse it
-                subtype = part.gettype()
-                if subtype == 'text/plain' and not content:
-                    # The first text/plain part is the message content.
-                    content = self.get_part_data_decoded(part) 
-                elif subtype == 'message/rfc822':
-                    # handle message/rfc822 specially - the name should be
-                    # the subject of the actual e-mail embedded here
-                    i = part.fp.tell()
-                    mailmess = Message(part.fp)
-                    name = mailmess.getheader('subject')
-                    part.fp.seek(i)
-                    attachments.append((name, 'message/rfc822', part.fp.read()))
-                elif subtype == 'multipart/alternative':
-                    # Search for text/plain in message with attachment and
-                    # alternative text representation
-                    # skip over intro to first boundary
-                    part.getPart()
-                    while 1:
-                        # get the next part
-                        subpart = part.getPart()
-                        if subpart is None:
-                            break
-                        # parse it
-                        if subpart.gettype() == 'text/plain' and not content:
-                            content = self.get_part_data_decoded(subpart) 
-                else:
-                    # try name on Content-Type
-                    name = part.getparam('name')
-                    if name:
-                        name = name.strip()
-                    if not name:
-                        disp = part.getheader('content-disposition', None)
-                        if disp:
-                            name = getparam(disp, 'filename')
-                            if name:
-                                name = name.strip()
-                    # this is just an attachment
-                    data = self.get_part_data_decoded(part) 
-                    attachments.append((name, part.gettype(), data))
-            if content is None:
-                raise MailUsageError, '''
-Roundup requires the submission to be plain text. The message parser could
-not find a text/plain part to use.
-'''
-
-        elif content_type[:10] == 'multipart/':
-            # skip over the intro to the first boundary
-            message.getPart()
-            content = None
-            while 1:
-                # get the next part
-                part = message.getPart()
-                if part is None:
-                    break
-                # parse it
-                if part.gettype() == 'text/plain' and not content:
-                    content = self.get_part_data_decoded(part) 
-            if content is None:
-                raise MailUsageError, '''
-Roundup requires the submission to be plain text. The message parser could
-not find a text/plain part to use.
-'''
-
-        elif content_type != 'text/plain':
+        content, attachments = message.extract_content()
+        if content is None:
             raise MailUsageError, '''
 Roundup requires the submission to be plain text. The message parser could
 not find a text/plain part to use.
 '''
-
-        else:
-            content = self.get_part_data_decoded(message) 
  
         # figure how much we should muck around with the email body
         keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
@@ -789,27 +799,27 @@ not find a text/plain part to use.
         # 
         # handle the attachments
         #
-        files = []
-        for (name, mime_type, data) in attachments:
-            if not name:
-                name = "unnamed"
-            files.append(self.db.file.create(type=mime_type, name=name,
-                content=data, **file_props))
-        # attach the files to the issue
-        if nodeid:
-            # extend the existing files list
-            fileprop = cl.get(nodeid, 'files')
-            fileprop.extend(files)
-            props['files'] = fileprop
-        else:
-            # pre-load the files list
-            props['files'] = files
-
+        if properties.has_key('files'):
+            files = []
+            for (name, mime_type, data) in attachments:
+                if not name:
+                    name = "unnamed"
+                files.append(self.db.file.create(type=mime_type, name=name,
+                                                 content=data, **file_props))
+            # attach the files to the issue
+            if nodeid:
+                # extend the existing files list
+                fileprop = cl.get(nodeid, 'files')
+                fileprop.extend(files)
+                props['files'] = fileprop
+            else:
+                # pre-load the files list
+                props['files'] = files
 
         # 
         # create the message if there's a message body (content)
         #
-        if content:
+        if (content and properties.has_key('messages')):
             message_id = self.db.msg.create(author=author,
                 recipients=recipients, date=date.Date('.'), summary=summary,
                 content=content, files=files, messageid=messageid,
@@ -851,11 +861,10 @@ There was a problem with the message you sent:
         return nodeid
 
  
-def setPropArrayFromString(self, cl, propString, nodeid = None):
+def setPropArrayFromString(self, cl, propString, nodeid=None):
     ''' takes string of form prop=value,value;prop2=value
         and returns (error, prop[..])
     '''
-    properties = cl.getprops()
     props = {}
     errors = []
     for prop in string.split(propString, ';'):
@@ -866,100 +875,13 @@ def setPropArrayFromString(self, cl, propString, nodeid = None):
             errors.append('not of form [arg=value,value,...;'
                 'arg=value,value,...]')
             return (errors, props)
-
-        # ensure it's a valid property name
+        # convert the value to a hyperdb-usable value
         propname = propname.strip()
         try:
-            proptype =  properties[propname]
-        except KeyError:
-            errors.append('refers to an invalid property: "%s"'%propname)
-            continue
-
-        # convert the string value to a real property value
-        if isinstance(proptype, hyperdb.String):
-            props[propname] = value.strip()
-        if isinstance(proptype, hyperdb.Password):
-            props[propname] = password.Password(value.strip())
-        elif isinstance(proptype, hyperdb.Date):
-            try:
-                props[propname] = date.Date(value.strip()).local(self.db.getUserTimezone())
-            except ValueError, message:
-                errors.append('contains an invalid date for %s.'%propname)
-        elif isinstance(proptype, hyperdb.Interval):
-            try:
-                props[propname] = date.Interval(value)
-            except ValueError, message:
-                errors.append('contains an invalid date interval for %s.'%
-                    propname)
-        elif isinstance(proptype, hyperdb.Link):
-            linkcl = self.db.classes[proptype.classname]
-            propkey = linkcl.labelprop(default_to_id=1)
-            try:
-                props[propname] = linkcl.lookup(value)
-            except KeyError, message:
-                errors.append('"%s" is not a value for %s.'%(value, propname))
-        elif isinstance(proptype, hyperdb.Multilink):
-            # get the linked class
-            linkcl = self.db.classes[proptype.classname]
-            propkey = linkcl.labelprop(default_to_id=1)
-            if nodeid:
-                curvalue = cl.get(nodeid, propname)
-            else:
-                curvalue = []
-
-            # handle each add/remove in turn
-            # keep an extra list for all items that are
-            # definitely in the new list (in case of e.g.
-            # <propname>=A,+B, which should replace the old
-            # list with A,B)
-            set = 0
-            newvalue = []
-            for item in value.split(','):
-                item = item.strip()
-
-                # handle +/-
-                remove = 0
-                if item.startswith('-'):
-                    remove = 1
-                    item = item[1:]
-                elif item.startswith('+'):
-                    item = item[1:]
-                else:
-                    set = 1
-
-                # look up the value
-                try:
-                    item = linkcl.lookup(item)
-                except KeyError, message:
-                    errors.append('"%s" is not a value for %s.'%(item,
-                        propname))
-                    continue
-
-                # perform the add/remove
-                if remove:
-                    try:
-                        curvalue.remove(item)
-                    except ValueError:
-                        errors.append('"%s" is not currently in for %s.'%(item,
-                            propname))
-                        continue
-                else:
-                    newvalue.append(item)
-                    if item not in curvalue:
-                        curvalue.append(item)
-
-            # that's it, set the new Multilink property value,
-            # or overwrite it completely
-            if set:
-                props[propname] = newvalue
-            else:
-                props[propname] = curvalue
-        elif isinstance(proptype, hyperdb.Boolean):
-            value = value.strip()
-            props[propname] = value.lower() in ('yes', 'true', 'on', '1')
-        elif isinstance(proptype, hyperdb.Number):
-            value = value.strip()
-            props[propname] = float(value)
+            props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+                propname, value)
+        except hyperdb.HyperdbValueError, message:
+            errors.append(message)
     return errors, props
 
 
@@ -1036,7 +958,7 @@ def uidFromAddress(db, address, create=1, **user_props):
 def parseContent(content, keep_citations, keep_body,
         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
         eol=re.compile(r'[\r\n]+'), 
-        signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
+        signature=re.compile(r'^[>|\s]*-- ?$'),
         original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
     ''' The message body is divided into sections by blank lines.
         Sections where the second and all subsequent lines begin with a ">"