Code

set title on issues even when the email body is empty (sf bug 727430)
[roundup.git] / roundup / mailgw.py
index 3f62e415520b3f5a44d69007cd398380a15e51a0..ca9ed5a41aafd5db8af55116dbb737369ef9933e 100644 (file)
@@ -73,13 +73,14 @@ 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.100 2002-12-10 00:11:15 richard Exp $
+$Id: mailgw.py,v 1.121 2003-04-27 02:16:46 richard Exp $
 '''
 
 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
 import time, random, sys
-import traceback, MimeWriter
-import hyperdb, date, password
+import traceback, MimeWriter, rfc822
+
+from roundup import hyperdb, date, password, rfc2822
 
 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
 
@@ -92,6 +93,10 @@ class MailUsageError(ValueError):
 class MailUsageHelp(Exception):
     pass
 
+class MailLoop(Exception):
+    ''' We've seen this message before... '''
+    pass
+
 class Unauthorized(Exception):
     """ Access denied """
 
@@ -107,6 +112,55 @@ def initialiseSecurity(security):
         description="User may use the email interface")
     security.addPermissionToRole('Admin', p)
 
+def getparam(str, param):
+    ''' From the rfc822 "header" string, extract "param" if it appears.
+    '''
+    if ';' not in str:
+        return None
+    str = str[str.index(';'):]
+    while str[:1] == ';':
+        str = str[1:]
+        if ';' in str:
+            # XXX Should parse quotes!
+            end = str.index(';')
+        else:
+            end = len(str)
+        f = str[:end]
+        if '=' in f:
+            i = f.index('=')
+            if f[:i].strip().lower() == param:
+                return rfc822.unquote(f[i+1:].strip())
+    return None
+
+def openSMTPConnection(config):
+    ''' Open an SMTP connection to the mailhost specified in the config
+    '''
+    smtp = smtplib.SMTP(config.MAILHOST)
+
+    # use TLS?
+    use_tls = getattr(config, 'MAILHOST_TLS', 'no')
+    if use_tls == 'yes':
+        # do we have key files too?
+        keyfile = getattr(config, 'MAILHOST_TLS_KEYFILE', '')
+        if keyfile:
+            certfile = getattr(config, 'MAILHOST_TLS_CERTFILE', '')
+            if certfile:
+                args = (keyfile, certfile)
+            else:
+                args = (keyfile, )
+        else:
+            args = ()
+        # start the TLS
+        smtp.starttls(*args)
+
+    # ok, now do we also need to log in?
+    mailuser = getattr(config, 'MAILUSER', None)
+    if mailuser:
+        smtp.login(*config.MAILUSER)
+
+    # that's it, a fully-configured SMTP connection ready to go
+    return smtp
+
 class Message(mimetools.Message):
     ''' subclass mimetools.Message so we can retrieve the parts of the
         message...
@@ -130,14 +184,19 @@ class Message(mimetools.Message):
         s.seek(0)
         return Message(s)
 
-subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re|aw)\W\s*)*'
+    def getheader(self, name, default=None):
+        hdr = mimetools.Message.getheader(self, name, default)
+        return rfc2822.decode_header(hdr)
+subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*'
     r'\s*(?P<quote>")?(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
     r'\s*(?P<title>[^[]+)?"?(\[(?P<args>.+?)\])?', re.I)
 
 class MailGW:
-    def __init__(self, instance, db):
+    def __init__(self, instance, db, arguments={}):
         self.instance = instance
         self.db = db
+        self.arguments = arguments
 
         # should we trap exceptions (normal usage) or pass them through
         # (for testing)
@@ -185,7 +244,12 @@ class MailGW:
         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
         return 0
 
-    def do_pop(self, server, user='', password=''):
+    def do_apop(self, server, user='', password=''):
+        ''' Do authentication POP
+        '''
+        self.do_pop(server, user, password, apop=1)
+
+    def do_pop(self, server, user='', password='', apop=0):
         '''Read a series of messages from the specified POP server.
         '''
         import getpass, poplib, socket
@@ -205,8 +269,11 @@ class MailGW:
         except socket.error, message:
             print "POP server error:", message
             return 1
-        server.user(user)
-        server.pass_(password)
+        if apop:
+            server.apop(user, password)
+        else:
+            server.user(user)
+            server.pass_(password)
         numMessages = len(server.list()[1])
         for i in range(1, numMessages+1):
             # retr: returns 
@@ -270,8 +337,12 @@ class MailGW:
                 m = ['']
                 m.append(str(value))
                 m = self.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')
@@ -285,6 +356,7 @@ class MailGW:
                 m = self.bounce_message(message, sendto, m)
         else:
             # 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]
             m = ['Subject: badly formed message from mail gateway']
             m.append('')
@@ -297,12 +369,12 @@ class MailGW:
 
         # now send the message
         if SENDMAILDEBUG:
-            open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
+            open(SENDMAILDEBUG, 'a').write('From: %s\nTo: %s\n%s\n'%(
                 self.instance.config.ADMIN_EMAIL, ', '.join(sendto),
                     m.getvalue()))
         else:
             try:
-                smtp = smtplib.SMTP(self.instance.config.MAILHOST)
+                smtp = openSMTPConnection(self.instance.config)
                 smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto,
                     m.getvalue())
             except socket.error, value:
@@ -319,14 +391,17 @@ class MailGW:
         '''
         msg = cStringIO.StringIO()
         writer = MimeWriter.MimeWriter(msg)
+        writer.addheader('X-Roundup-Loop', 'hello')
         writer.addheader('Subject', subject)
         writer.addheader('From', '%s <%s>'% (self.instance.config.TRACKER_NAME,
             self.instance.config.TRACKER_EMAIL))
         writer.addheader('To', ','.join(sendto))
+        writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
+            time.gmtime()))
         writer.addheader('MIME-Version', '1.0')
         part = writer.startmultipartbody('mixed')
         part = writer.nextpart()
-        body = part.startbody('text/plain')
+        body = part.startbody('text/plain; charset=utf-8')
         body.write('\n'.join(error))
 
         # attach the original message to the returned message
@@ -364,17 +439,50 @@ class MailGW:
         else:
             # take it as text
             data = part.fp.read()
-        return data
+        
+        # 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
 
     def handle_message(self, message):
         ''' message - a Message instance
 
         Parse the message as per the module docstring.
         '''
+        # detect loops
+        if message.getheader('x-roundup-loop', ''):
+            raise MailLoop
+
+        # XXX Don't enable. This doesn't work yet.
+#  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
+        # handle delivery to addresses like:tracker+issue25@some.dom.ain
+        # use the embedded issue number as our issue
+#        if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
+#                self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
+#            issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
+#            for header in ['to', 'cc', 'bcc']:
+#                addresses = message.getheader(header, '')
+#            if addresses:
+#              # FIXME, this only finds the first match in the addresses.
+#                issue = re.search(issue_re, addresses, 'i')
+#                if issue:
+#                    classname = issue.group('classname')
+#                    nodeid = issue.group('nodeid')
+#                    break
+
         # handle the subject line
         subject = message.getheader('subject', '')
 
-        if subject.strip() == 'help':
+        if subject.strip().lower() == 'help':
             raise MailUsageHelp
 
         m = subject_re.match(subject)
@@ -463,6 +571,48 @@ does not exist.
 Subject was: "%s"
 '''%(nodeid, subject)
 
+        # Handle the arguments specified by the email gateway command line.
+        # We do this by looping over the list of self.arguments looking for
+        # a -C to tell us what class then the -S setting string.
+        msg_props = {}
+        user_props = {}
+        file_props = {}
+        issue_props = {}
+        # so, if we have any arguments, use them
+        if self.arguments:
+            current_class = 'msg'
+            for option, propstring in self.arguments:
+                if option in ( '-C', '--class'):
+                    current_class = propstring.strip()
+                    if current_class not in ('msg', 'file', 'user', 'issue'):
+                        raise MailUsageError, '''
+The mail gateway is not properly set up. Please contact
+%s and have them fix the incorrect class specified as:
+  %s
+'''%(self.instance.config.ADMIN_EMAIL, current_class)
+                if option in ('-S', '--set'):
+                    if current_class == 'issue' :
+                        errors, issue_props = setPropArrayFromString(self,
+                            cl, propstring.strip(), nodeid)
+                    elif current_class == 'file' :
+                        temp_cl = self.db.getclass('file')
+                        errors, file_props = setPropArrayFromString(self,
+                            temp_cl, propstring.strip())
+                    elif current_class == 'msg' :
+                        temp_cl = self.db.getclass('msg')
+                        errors, msg_props = setPropArrayFromString(self,
+                            temp_cl, propstring.strip())
+                    elif current_class == 'user' :
+                        temp_cl = self.db.getclass('user')
+                        errors, user_props = setPropArrayFromString(self,
+                            temp_cl, propstring.strip())
+                    if errors:
+                        raise MailUsageError, '''
+The mail gateway is not properly set up. Please contact
+%s and have them fix the incorrect properties:
+  %s
+'''%(self.instance.config.ADMIN_EMAIL, errors)
+
         #
         # handle the users
         #
@@ -522,17 +672,12 @@ Unknown address: %s
 
             # look up the recipient - create if necessary (and we're
             # allowed to)
-            recipient = uidFromAddress(self.db, recipient, create)
+            recipient = uidFromAddress(self.db, recipient, create, **user_props)
 
             # if all's well, add the recipient to the list
             if recipient:
                 recipients.append(recipient)
 
-        #
-        # extract the args
-        #
-        subject_args = m.group('args')
-
         #
         # handle the subject argument list
         #
@@ -541,113 +686,7 @@ Unknown address: %s
         props = {}
         args = m.group('args')
         if args:
-            errors = []
-            for prop in string.split(args, ';'):
-                # extract the property name and value
-                try:
-                    propname, value = prop.split('=')
-                except ValueError, message:
-                    errors.append('not of form [arg=value,'
-                        'value,...;arg=value,value...]')
-                    break
-
-                # ensure it's a valid property name
-                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())
-                    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] = int(value)
-
+            errors, props = setPropArrayFromString(self, cl, args, nodeid)
             # handle any errors parsing the argument list
             if errors:
                 errors = '\n- '.join(errors)
@@ -658,6 +697,11 @@ There were problems handling your subject line argument list:
 Subject was: "%s"
 '''%(errors, subject)
 
+
+        # set the issue title to the subject
+        if properties.has_key('title') and not issue_props.has_key('title'):
+            issue_props['title'] = title.strip()
+
         #
         # handle message-id and in-reply-to
         #
@@ -721,13 +765,30 @@ Subject was: "%s"
                     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').strip()
+                    name = part.getparam('name')
+                    if name:
+                        name = name.strip()
                     if not name:
                         disp = part.getheader('content-disposition', None)
                         if disp:
-                            name = disp.getparam('filename').strip()
+                            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))
@@ -773,6 +834,7 @@ not find a text/plain part to use.
         # parse the body of the message, stripping out bits as appropriate
         summary, content = parseContent(content, keep_citations, 
             keep_body)
+        content = content.strip()
 
         # 
         # handle the attachments
@@ -782,7 +844,17 @@ not find a text/plain part to use.
             if not name:
                 name = "unnamed"
             files.append(self.db.file.create(type=mime_type, name=name,
-                content=data))
+                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)
@@ -791,7 +863,7 @@ not find a text/plain part to use.
             message_id = self.db.msg.create(author=author,
                 recipients=recipients, date=date.Date('.'), summary=summary,
                 content=content, files=files, messageid=messageid,
-                inreplyto=inreplyto)
+                inreplyto=inreplyto, **msg_props)
 
             # attach the message to the node
             if nodeid:
@@ -803,14 +875,16 @@ not find a text/plain part to use.
                 # pre-load the messages list
                 props['messages'] = [message_id]
 
-                # set the title to the subject
-                if properties.has_key('title') and not props.has_key('title'):
-                    props['title'] = title
-
         #
         # perform the node change / create
         #
         try:
+            # merge the command line props defined in issue_props into
+            # the props dictionary because function(**props, **issue_props)
+            # is a syntax error.
+            for prop in issue_props.keys() :
+                if not props.has_key(prop) :
+                    props[prop] = issue_props[prop]
             if nodeid:
                 cl.set(nodeid, **props)
             else:
@@ -826,6 +900,119 @@ There was a problem with the message you sent:
 
         return nodeid
 
+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, ';'):
+        # extract the property name and value
+        try:
+            propname, value = prop.split('=')
+        except ValueError, message:
+            errors.append('not of form [arg=value,value,...;'
+                'arg=value,value,...]')
+            return (errors, props)
+
+        # ensure it's a valid property name
+        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)
+    return errors, props
+
+
 def extractUserFromList(userClass, users):
     '''Given a list of users, try to extract the first non-anonymous user
        and return that user, otherwise return None
@@ -843,23 +1030,27 @@ def extractUserFromList(userClass, users):
         return users[0]
     return None
 
-def uidFromAddress(db, address, create=1):
+
+def uidFromAddress(db, address, create=1, **user_props):
     ''' address is from the rfc822 module, and therefore is (name, addr)
 
         user is created if they don't exist in the db already
+        user_props may supply additional user information
     '''
     (realname, address) = address
 
     # try a straight match of the address
     user = extractUserFromList(db.user, db.user.stringFind(address=address))
-    if user is not None: return user
+    if user is not None:
+        return user
 
     # try the user alternate addresses if possible
     props = db.user.getprops()
     if props.has_key('alternate_addresses'):
         users = db.user.filter(None, {'alternate_addresses': address})
         user = extractUserFromList(db.user, users)
-        if user is not None: return user
+        if user is not None:
+            return user
 
     # try to match the username to the address (for local
     # submissions where the address is empty)
@@ -867,16 +1058,36 @@ def uidFromAddress(db, address, create=1):
 
     # couldn't match address or username, so create a new user
     if create:
-        return db.user.create(username=address, address=address,
-            realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES)
+        # generate a username
+        if '@' in address:
+            username = address.split('@')[0]
+        else:
+            username = address
+        trying = username
+        n = 0
+        while 1:
+            try:
+                # does this username exist already?
+                db.user.lookup(trying)
+            except KeyError:
+                break
+            n += 1
+            trying = username + str(n)
+
+        # create!
+        return db.user.create(username=trying, address=address,
+            realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
+            password=password.Password(password.generatePassword()),
+            **user_props)
     else:
         return 0
 
+
 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-----$')):
+        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 ">"
         or "|" character are considered "quoting sections". The first line of
@@ -930,7 +1141,7 @@ def parseContent(content, keep_citations, keep_body,
         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
             # lose any signature
             break
-        elif original_message.match(lines[0]):
+        elif original_msg.match(lines[0]):
             # ditch the stupid Outlook quoting of the entire original message
             break