Code

More informative error message
[roundup.git] / roundup / mailgw.py
index 988b459e7402c61d83b6144a73c5a52717c6ce50..53dc401d20dea4d9c4ba0613aa34621586ec9f67 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.58 2002-01-23 21:41:56 richard Exp $
+$Id: mailgw.py,v 1.74 2002-05-29 01:16:17 richard Exp $
 '''
 
 
@@ -120,7 +120,7 @@ class Message(mimetools.Message):
         return Message(s)
 
 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re|aw)\s*\W?\s*)*'
-    r'\s*(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])'
+    r'\s*(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
     r'\s*(?P<title>[^[]+)?(\[(?P<args>.+?)\])?', re.I)
 
 class MailGW:
@@ -131,7 +131,7 @@ class MailGW:
     def main(self, fp):
         ''' fp - the file from which to read the Message.
         '''
-        self.handle_Message(Message(fp))
+        return self.handle_Message(Message(fp))
 
     def handle_Message(self, message):
         '''Handle an RFC822 Message
@@ -176,9 +176,9 @@ class MailGW:
                 # bounce the message back to the sender with the error message
                 sendto = [sendto[0][1], self.instance.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('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
@@ -261,6 +261,25 @@ class MailGW:
         writer.lastpart()
         return msg
 
+    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()
+        return data
+
     def handle_message(self, message):
         ''' message - a Message instance
 
@@ -273,6 +292,20 @@ class MailGW:
             raise MailUsageHelp
 
         m = subject_re.match(subject)
+
+        # check for well-formed subject line
+        if m:
+            # get the classname
+            classname = m.group('classname')
+            if classname is None:
+                # no classname, fallback on the default
+                if hasattr(self.instance, 'MAIL_DEFAULT_CLASS') and \
+                        self.instance.MAIL_DEFAULT_CLASS:
+                    classname = self.instance.MAIL_DEFAULT_CLASS
+                else:
+                    # fail
+                    m = None
+
         if not m:
             raise MailUsageError, '''
 The message you sent to roundup did not contain a properly formed subject
@@ -288,8 +321,7 @@ line. The subject must contain a class name or designator to indicate the
 Subject was: "%s"
 '''%subject
 
-        # get the classname
-        classname = m.group('classname')
+        # get the class
         try:
             cl = self.db.getclass(classname)
         except KeyError:
@@ -312,7 +344,7 @@ Subject was: "%s"
             title = ''
 
         # but we do need either a title or a nodeid...
-        if not nodeid and not title:
+        if nodeid is None and not title:
             raise MailUsageError, '''
 I cannot match your message to a node in the database - you need to either
 supply a full node identifier (with number, eg "[issue123]" or keep the
@@ -321,115 +353,149 @@ previous subject title intact so I can match that.
 Subject was: "%s"
 '''%subject
 
-        # extract the args
-        subject_args = m.group('args')
-
         # If there's no nodeid, check to see if this is a followup and
         # maybe someone's responded to the initial mail that created an
         # entry. Try to find the matching nodes with the same title, and
         # use the _last_ one matched (since that'll _usually_ be the most
         # recent...)
-        if not nodeid and m.group('refwd'):
+        if nodeid is None and m.group('refwd'):
             l = cl.stringFind(title=title)
             if l:
                 nodeid = l[-1]
 
-        # start of the props
+        # if a nodeid was specified, make sure it's valid
+        if nodeid is not None and not cl.hasnode(nodeid):
+            raise MailUsageError, '''
+The node specified by the designator in the subject of your message ("%s")
+does not exist.
+
+Subject was: "%s"
+'''%(nodeid, subject)
+
+        #
+        # extract the args
+        #
+        subject_args = m.group('args')
+
+        #
+        # handle the subject argument list
+        #
+        # figure what the properties of this Class are
         properties = cl.getprops()
         props = {}
-
-        # handle the args
         args = m.group('args')
         if args:
+            errors = []
             for prop in string.split(args, ';'):
                 # extract the property name and value
                 try:
-                    key, value = prop.split('=')
+                    propname, value = prop.split('=')
                 except ValueError, message:
-                    raise MailUsageError, '''
-Subject argument list not of form [arg=value,value,...;arg=value,value...]
-   (specific exception message was "%s")
-
-Subject was: "%s"
-'''%(message, subject)
+                    errors.append('not of form [arg=value,'
+                        'value,...;arg=value,value...]')
+                    break
 
                 # ensure it's a valid property name
-                key = key.strip()
+                propname = propname.strip()
                 try:
-                    proptype =  properties[key]
+                    proptype =  properties[propname]
                 except KeyError:
-                    raise MailUsageError, '''
-Subject argument list refers to an invalid property: "%s"
-
-Subject was: "%s"
-'''%(key, subject)
+                    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[key] = value.strip()
+                    props[propname] = value.strip()
                 if isinstance(proptype, hyperdb.Password):
-                    props[key] = password.Password(value.strip())
+                    props[propname] = password.Password(value.strip())
                 elif isinstance(proptype, hyperdb.Date):
                     try:
-                        props[key] = date.Date(value.strip())
+                        props[propname] = date.Date(value.strip())
                     except ValueError, message:
-                        raise UsageError, '''
-Subject argument list contains an invalid date for %s.
-
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
+                        errors.append('contains an invalid date for '
+                            '%s.'%propname)
                 elif isinstance(proptype, hyperdb.Interval):
                     try:
-                        props[key] = date.Interval(value) # no strip needed
+                        props[propname] = date.Interval(value)
                     except ValueError, message:
-                        raise UsageError, '''
-Subject argument list contains an invalid date interval for %s.
-
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
+                        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[key] = linkcl.lookup(value)
+                        props[propname] = linkcl.lookup(value)
                     except KeyError, message:
-                        raise MailUsageError, '''
-Subject argument list contains an invalid value for %s.
-
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
+                        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
                     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:]
+
+                        # look up the value
                         try:
                             item = linkcl.lookup(item)
                         except KeyError, message:
-                            raise MailUsageError, '''
-Subject argument list contains an invalid value for %s.
+                            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:
+                            if item not in curvalue:
+                                curvalue.append(item)
+
+                    # that's it, set the new Multilink property value
+                    props[propname] = curvalue
+
+            # handle any errors parsing the argument list
+            if errors:
+                errors = '\n- '.join(errors)
+                raise MailUsageError, '''
+There were problems handling your subject line argument list:
+- %s
 
-Error was: %s
 Subject was: "%s"
-'''%(key, message, subject)
-                        if props.has_key(key):
-                            props[key].append(item)
-                        else:
-                            props[key] = [item]
+'''%(errors, subject)
 
         #
         # handle the users
         #
 
-        # Don't create users if ANONYMOUS_REGISTER is denied
-        if self.instance.ANONYMOUS_REGISTER == 'deny':
+        # Don't create users if ANONYMOUS_REGISTER_MAIL is denied
+        # ... fall back on ANONYMOUS_REGISTER if the other doesn't exist
+        create = 1
+        if hasattr(self.instance, 'ANONYMOUS_REGISTER_MAIL'):
+            if self.instance.ANONYMOUS_REGISTER_MAIL == 'deny':
+                create = 0
+        elif self.instance.ANONYMOUS_REGISTER == 'deny':
             create = 0
-        else:
-            create = 1
+
         author = self.db.uidFromAddress(message.getaddrlist('from')[0],
             create=create)
         if not author:
@@ -457,7 +523,14 @@ Unknown address: %s
             r = recipient[1].strip().lower()
             if r == tracker_email or not r:
                 continue
-            recipients.append(self.db.uidFromAddress(recipient))
+
+            # look up the recipient - create if necessary (and we're
+            # allowed to)
+            recipient = self.db.uidFromAddress(recipient, create)
+
+            # if all's well, add the recipient to the list
+            if recipient:
+                recipients.append(recipient)
 
         #
         # handle message-id and in-reply-to
@@ -474,6 +547,32 @@ Unknown address: %s
         #
         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()
@@ -486,12 +585,8 @@ Unknown address: %s
                 # parse it
                 subtype = part.gettype()
                 if subtype == 'text/plain' and not content:
-                    # add all text/plain parts to the message content
-                    if content is None:
-                        content = part.fp.read()
-                    else:
-                        content = content + part.fp.read()
-
+                    # 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
@@ -500,23 +595,12 @@ Unknown address: %s
                     name = mailmess.getheader('subject')
                     part.fp.seek(i)
                     attachments.append((name, 'message/rfc822', part.fp.read()))
-
                 else:
                     # try name on Content-Type
                     name = part.getparam('name')
                     # this is just an attachment
-                    encoding = part.getencoding()
-                    if encoding == 'base64':
-                        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())
+                    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
@@ -534,8 +618,7 @@ not find a text/plain part to use.
                     break
                 # parse it
                 if part.gettype() == 'text/plain' and not content:
-                    # this one's our content
-                    content = part.fp.read()
+                    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
@@ -549,9 +632,17 @@ not find a text/plain part to use.
 '''
 
         else:
-            content = message.fp.read()
-
-        summary, content = parseContent(content)
+            content = self.get_part_data_decoded(message) 
+        # figure how much we should muck around with the email body
+        keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT',
+            'no') == 'yes'
+        keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED',
+            'no') == 'yes'
+
+        # parse the body of the message, stripping out bits as appropriate
+        summary, content = parseContent(content, keep_citations, 
+            keep_body)
 
         # 
         # handle the attachments
@@ -563,144 +654,53 @@ not find a text/plain part to use.
             files.append(self.db.file.create(type=mime_type, name=name,
                 content=data))
 
+        # 
+        # create the message if there's a message body (content)
         #
-        # now handle the db stuff
-        #
-        if nodeid:
-            # If an item designator (class name and id number) is found there,
-            # the newly created "msg" node is added to the "messages" property
-            # for that item, and any new "file" nodes are added to the "files" 
-            # property for the item. 
-
-            # if the message is currently 'unread' or 'resolved', then set
-            # it to 'chatting'
-            if properties.has_key('status'):
-                try:
-                    # determine the id of 'unread', 'resolved' and 'chatting'
-                    unread_id = self.db.status.lookup('unread')
-                    resolved_id = self.db.status.lookup('resolved')
-                    chatting_id = self.db.status.lookup('chatting')
-                except KeyError:
-                    pass
-                else:
-                    current_status = cl.get(nodeid, 'status')
-                    if (not props.has_key('status') and
-                            current_status == unread_id or
-                            current_status == resolved_id):
-                        props['status'] = chatting_id
-
-            # add nosy in arguments to issue's nosy list
-            if not props.has_key('nosy'): props['nosy'] = []
-            n = {}
-            for nid in cl.get(nodeid, 'nosy'):
-                n[nid] = 1
-            for value in props['nosy']:
-                if self.db.hasnode('user', value):
-                    nid = value
-                else: 
-                    continue
-                if n.has_key(nid): continue
-                n[nid] = 1
-            props['nosy'] = n.keys()
-            # add assignedto to the nosy list
-            if props.has_key('assignedto'):
-                assignedto = props['assignedto']
-                if assignedto not in props['nosy']:
-                    props['nosy'].append(assignedto)
-
+        if content:
             message_id = self.db.msg.create(author=author,
                 recipients=recipients, date=date.Date('.'), summary=summary,
                 content=content, files=files, messageid=messageid,
                 inreplyto=inreplyto)
-            try:
+
+            # attach the message to the node
+            if nodeid:
+                # add the message to the node's list
                 messages = cl.get(nodeid, 'messages')
-            except IndexError:
-                raise MailUsageError, '''
-The node specified by the designator in the subject of your message ("%s")
-does not exist.
+                messages.append(message_id)
+                props['messages'] = messages
+            else:
+                # pre-load the messages list
+                props['messages'] = [message_id]
 
-Subject was: "%s"
-'''%(nodeid, subject)
-            messages.append(message_id)
-            props['messages'] = messages
+                # set the title to the subject
+                if properties.has_key('title') and not props.has_key('title'):
+                    props['title'] = title
 
-            # now apply the changes
-            try:
+        #
+        # perform the node change / create
+        #
+        try:
+            if nodeid:
                 cl.set(nodeid, **props)
-            except (TypeError, IndexError, ValueError), message:
-                raise MailUsageError, '''
-There was a problem with the message you sent:
-   %s
-'''%message
-            # commit the changes to the DB
-            self.db.commit()
-        else:
-            # If just an item class name is found there, we attempt to create a
-            # new item of that class with its "messages" property initialized to
-            # contain the new "msg" node and its "files" property initialized to
-            # contain any new "file" nodes. 
-            message_id = self.db.msg.create(author=author,
-                recipients=recipients, date=date.Date('.'), summary=summary,
-                content=content, files=files, messageid=messageid,
-                inreplyto=inreplyto)
-
-            # pre-set the issue to unread
-            if properties.has_key('status') and not props.has_key('status'):
-                try:
-                    # determine the id of 'unread'
-                    unread_id = self.db.status.lookup('unread')
-                except KeyError:
-                    pass
-                else:
-                    props['status'] = '1'
-
-            # set the title to the subject
-            if properties.has_key('title') and not props.has_key('title'):
-                props['title'] = title
-
-            # pre-load the messages list
-            props['messages'] = [message_id]
-
-            # set up (clean) the nosy list
-            nosy = props.get('nosy', [])
-            n = {}
-            for value in nosy:
-                nid = value
-                if n.has_key(nid): continue
-                n[nid] = 1
-            props['nosy'] = n.keys()
-            # add on the recipients of the message
-            for recipient in recipients:
-                if not n.has_key(recipient):
-                    props['nosy'].append(recipient)
-                    n[recipient] = 1
-
-            # add the author to the nosy list
-            if not n.has_key(author):
-                props['nosy'].append(author)
-                n[author] = 1
-
-            # add assignedto to the nosy list
-            if properties.has_key('assignedto') and props.has_key('assignedto'):
-                assignedto = props['assignedto']
-                if not n.has_key(assignedto):
-                    props['nosy'].append(assignedto)
-                    n[assignedto] = 1
-
-            # and attempt to create the new node
-            try:
+            else:
                 nodeid = cl.create(**props)
-            except (TypeError, IndexError, ValueError), message:
-                raise MailUsageError, '''
+        except (TypeError, IndexError, ValueError), message:
+            raise MailUsageError, '''
 There was a problem with the message you sent:
    %s
 '''%message
 
-            # commit the new node(s) to the DB
-            self.db.commit()
+        # commit the changes to the DB
+        self.db.commit()
+
+        return nodeid
 
-def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
-        eol=re.compile(r'[\r\n]+'), signature=re.compile(r'^[>|\s]*[-_]+\s*$')):
+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*$'),
+        original_message=re.compile(r'^[>|\s]*-----Original Message-----$')):
     ''' The message body is divided into sections by blank lines.
     Sections where the second and all subsequent lines begin with a ">" or "|"
     character are considered "quoting sections". The first line of the first
@@ -732,9 +732,9 @@ def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
                 if line[0] not in '>|':
                     break
             else:
-                # TODO: people who want to keep quoted bits will want the
-                # next line...
-                # l.append(section)
+                # we keep quoted bits if specified in the config
+                if keep_citations:
+                    l.append(section)
                 continue
             # keep this section - it has reponse stuff in it
             if not summary:
@@ -748,15 +748,85 @@ def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
             # if we don't have our summary yet use the first line of this
             # section
             summary = lines[0]
-        elif signature.match(lines[0]):
+        elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
+            # lose any signature
+            break
+        elif original_message.match(lines[0]):
+            # ditch the stupid Outlook quoting of the entire original message
             break
 
         # and add the section to the output
         l.append(section)
-    return summary, '\n\n'.join(l)
+    # we only set content for those who want to delete cruft from the
+    # message body, otherwise the body is left untouched.
+    if not keep_body:
+        content = '\n\n'.join(l)
+    return summary, content
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.73  2002/05/22 04:12:05  richard
+#  . applied patch #558876 ] cgi client customization
+#    ... with significant additions and modifications ;)
+#    - extended handling of ML assignedto to all places it's handled
+#    - added more NotFound info
+#
+# Revision 1.72  2002/05/22 01:24:51  richard
+# Added note to MIGRATION about new config vars. Also made us more resilient
+# for upgraders. Reinstated list header style (oops)
+#
+# Revision 1.71  2002/05/08 02:40:55  richard
+# grr
+#
+# Revision 1.70  2002/05/06 23:40:07  richard
+# hrm
+#
+# Revision 1.69  2002/05/06 23:37:21  richard
+# Tweaking the signature deletion from mail messages.
+# Added nuking of the "-----Original Message-----" crap from Outlook.
+#
+# Revision 1.68  2002/05/02 07:56:34  richard
+# . added option to automatically add the authors and recipients of messages
+#   to the nosy lists with the options ADD_AUTHOR_TO_NOSY (default 'new') and
+#   ADD_RECIPIENTS_TO_NOSY (default 'new'). These settings emulate the current
+#   behaviour. Setting them to 'yes' will add the author/recipients to the nosy
+#   on messages that create issues and followup messages.
+# . added missing documentation for a few of the config option values
+#
+# Revision 1.67  2002/04/23 15:46:49  rochecompaan
+#  . stripping of the email message body can now be controlled through
+#    the config variables EMAIL_KEEP_QUOTED_TEST and
+#    EMAIL_LEAVE_BODY_UNCHANGED.
+#
+# Revision 1.66  2002/03/14 23:59:24  richard
+#  . #517734 ] web header customisation is obscure
+#
+# Revision 1.65  2002/02/15 00:13:38  richard
+#  . #503204 ] mailgw needs a default class
+#     - partially done - the setting of additional properties can wait for a
+#       better configuration system.
+#
+# Revision 1.64  2002/02/14 23:46:02  richard
+# . #516883 ] mail interface + ANONYMOUS_REGISTER
+#
+# Revision 1.63  2002/02/12 08:08:55  grubert
+#  . Clean up mail handling, multipart handling.
+#
+# Revision 1.62  2002/02/05 14:15:29  grubert
+#  . respect encodings in non multipart messages.
+#
+# Revision 1.61  2002/02/04 09:40:21  grubert
+#  . add test for multipart messages with first part being encoded.
+#
+# Revision 1.60  2002/02/01 07:43:12  grubert
+#  . mailgw checks encoding on first part too.
+#
+# Revision 1.59  2002/01/23 21:43:23  richard
+# tabnuke
+#
+# Revision 1.58  2002/01/23 21:41:56  richard
+#  . mailgw failures (unexpected ones) are forwarded to the roundup admin
+#
 # Revision 1.57  2002/01/22 22:27:43  richard
 #  . handle stripping of "AW:" from subject line
 #