Code

Added commentage to the dbinit files to help people with their
[roundup.git] / roundup / mailgw.py
index 988b459e7402c61d83b6144a73c5a52717c6ce50..4c99afb0a22de18a65b7283dbb4032c2a92a10e6 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. 
 
 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.73 2002-05-22 04:12:05 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*)*'
         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:
     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.
         '''
     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
 
     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 = ['']
                 # 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
                 m.append('----  traceback of failure  ----')
                 s = cStringIO.StringIO()
                 import traceback
@@ -261,6 +261,25 @@ class MailGW:
         writer.lastpart()
         return msg
 
         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
 
     def handle_message(self, message):
         ''' message - a Message instance
 
@@ -273,6 +292,20 @@ class MailGW:
             raise MailUsageHelp
 
         m = subject_re.match(subject)
             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
         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
 
 Subject was: "%s"
 '''%subject
 
-        # get the classname
-        classname = m.group('classname')
+        # get the class
         try:
             cl = self.db.getclass(classname)
         except KeyError:
         try:
             cl = self.db.getclass(classname)
         except KeyError:
@@ -425,11 +457,15 @@ Subject was: "%s"
         # handle the users
         #
 
         # 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
             create = 0
-        else:
-            create = 1
+
         author = self.db.uidFromAddress(message.getaddrlist('from')[0],
             create=create)
         if not author:
         author = self.db.uidFromAddress(message.getaddrlist('from')[0],
             create=create)
         if not author:
@@ -457,7 +493,14 @@ Unknown address: %s
             r = recipient[1].strip().lower()
             if r == tracker_email or not r:
                 continue
             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
 
         #
         # handle message-id and in-reply-to
@@ -474,6 +517,32 @@ Unknown address: %s
         #
         content_type =  message.gettype()
         attachments = []
         #
         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()
         if content_type == 'multipart/mixed':
             # skip over the intro to the first boundary
             part = message.getPart()
@@ -486,12 +555,8 @@ Unknown address: %s
                 # parse it
                 subtype = part.gettype()
                 if subtype == 'text/plain' and not content:
                 # 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
                 elif subtype == 'message/rfc822':
                     # handle message/rfc822 specially - the name should be
                     # the subject of the actual e-mail embedded here
@@ -500,23 +565,12 @@ Unknown address: %s
                     name = mailmess.getheader('subject')
                     part.fp.seek(i)
                     attachments.append((name, 'message/rfc822', part.fp.read()))
                     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
                 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))
                     attachments.append((name, part.gettype(), data))
-
             if content is None:
                 raise MailUsageError, '''
 Roundup requires the submission to be plain text. The message parser could
             if content is None:
                 raise MailUsageError, '''
 Roundup requires the submission to be plain text. The message parser could
@@ -534,8 +588,7 @@ not find a text/plain part to use.
                     break
                 # parse it
                 if part.gettype() == 'text/plain' and not content:
                     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
             if content is None:
                 raise MailUsageError, '''
 Roundup requires the submission to be plain text. The message parser could
@@ -549,9 +602,17 @@ not find a text/plain part to use.
 '''
 
         else:
 '''
 
         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
 
         # 
         # handle the attachments
@@ -589,25 +650,13 @@ not find a text/plain part to use.
                             current_status == resolved_id):
                         props['status'] = chatting_id
 
                             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 = {}
+            # update the nosy list
+            current = {}
             for nid in cl.get(nodeid, 'nosy'):
             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)
+                current[nid] = 1
+            self.updateNosy(cl, props, author, recipients, current)
 
 
+            # create the message
             message_id = self.db.msg.create(author=author,
                 recipients=recipients, date=date.Date('.'), summary=summary,
                 content=content, files=files, messageid=messageid,
             message_id = self.db.msg.create(author=author,
                 recipients=recipients, date=date.Date('.'), summary=summary,
                 content=content, files=files, messageid=messageid,
@@ -662,30 +711,7 @@ There was a problem with the message you sent:
             props['messages'] = [message_id]
 
             # set up (clean) the nosy 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
+            self.updateNosy(cl, props, author, recipients)
 
             # and attempt to create the new node
             try:
 
             # and attempt to create the new node
             try:
@@ -699,8 +725,60 @@ There was a problem with the message you sent:
             # commit the new node(s) to the DB
             self.db.commit()
 
             # commit the new node(s) to the DB
             self.db.commit()
 
-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*$')):
+        return nodeid
+
+    def updateNosy(self, cl, props, author, recipients, current=None):
+        '''Determine what the nosy list should be given:
+
+            props:      properties specified on the subject line of the message
+            author:     the sender of the message
+            recipients: the recipients (to, cc) of the message
+            current:    if the issue already exists, this is the current nosy
+                        list, as a dictionary.
+        '''
+        if current is None:
+            current = {}
+            ok = ('new', 'yes')
+        else:
+            ok = ('yes',)
+
+        # add nosy in arguments to issue's nosy list
+        nosy = props.get('nosy', [])
+        for value in nosy:
+            if not self.db.hasnode('user', value):
+                continue
+            if not current.has_key(value):
+                current[value] = 1
+
+        # add the author to the nosy list
+        if getattr(self.instance, 'ADD_AUTHOR_TO_NOSY', 'new') in ok:
+            if not current.has_key(author):
+                current[author] = 1
+
+        # add on the recipients of the message
+        if getattr(self.instance, 'ADD_RECIPIENTS_TO_NOSY', 'new') in ok:
+            for recipient in recipients:
+                if not current.has_key(recipient):
+                    current[recipient] = 1
+
+        # add assignedto(s) to the nosy list
+        if props.has_key('assignedto'):
+            propdef = cl.getprops()
+            if isinstance(propdef['assignedto'], hyperdb.Link):
+                assignedto_ids = [props['assignedto']]
+            elif isinstance(propdef['assignedto'], hyperdb.Multilink):
+                assignedto_ids = props['assignedto']
+            for assignedto_id in assignedto_ids:
+                if not current.has_key(assignedto_id):
+                    current[assignedto_id] = 1
+
+        props['nosy'] = current.keys()
+
+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
     ''' 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 +810,9 @@ def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
                 if line[0] not in '>|':
                     break
             else:
                 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:
                 continue
             # keep this section - it has reponse stuff in it
             if not summary:
@@ -748,15 +826,79 @@ 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]
             # 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)
             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 $
 
 #
 # $Log: not supported by cvs2svn $
+# 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
 #
 # Revision 1.57  2002/01/22 22:27:43  richard
 #  . handle stripping of "AW:" from subject line
 #