Code

Fix mailer (sf bug #817470) and add docstrings to prevent this from happening again.
[roundup.git] / roundup / mailgw.py
index b624b1bc6f560e3cc6db156344d38166c9bd7855..8ec27fe983d0e0689a8dc9c233bfbff7369cd855 100644 (file)
@@ -16,7 +16,7 @@
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 #
 
-__doc__ = '''
+"""
 An e-mail gateway for Roundup.
 
 Incoming messages are examined for multiple parts:
@@ -73,14 +73,15 @@ 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.62 2002-02-05 14:15:29 grubert Exp $
-'''
-
+$Id: mailgw.py,v 1.133 2003-10-04 11:21:47 jlgijsbers Exp $
+"""
 
 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
-import time, random
-import traceback, MimeWriter
-import hyperdb, date, password
+import time, random, sys
+import traceback, MimeWriter, rfc822
+
+from roundup import hyperdb, date, password, rfc2822
+from roundup.mailer import Mailer
 
 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
 
@@ -93,9 +94,45 @@ class MailUsageError(ValueError):
 class MailUsageHelp(Exception):
     pass
 
-class UnAuthorized(Exception):
+class MailLoop(Exception):
+    """ We've seen this message before... """
+    pass
+
+class Unauthorized(Exception):
     """ Access denied """
 
+def initialiseSecurity(security):
+    ''' Create some Permissions and Roles on the security object
+
+        This function is directly invoked by security.Security.__init__()
+        as a part of the Security object instantiation.
+    '''
+    security.addPermission(name="Email Registration",
+        description="Anonymous may register through e-mail")
+    p = security.addPermission(name="Email Access",
+        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
+
 class Message(mimetools.Message):
     ''' subclass mimetools.Message so we can retrieve the parts of the
         message...
@@ -119,33 +156,151 @@ class Message(mimetools.Message):
         s.seek(0)
         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<title>[^[]+)?(\[(?P<args>.+?)\])?', re.I)
-
+    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)
 class MailGW:
-    def __init__(self, instance, db):
+
+    # Matches subjects like:
+    # Re: "[issue1234] title of issue [status=resolved]"
+    subject_re = re.compile(r'''
+        (?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*\s*   # Re:
+        (?P<quote>")?                                 # Leading "
+        (\[(?P<classname>[^\d\s]+)                    # [issue..
+           (?P<nodeid>\d+)?                           # ..1234]
+         \])?\s*
+        (?P<title>[^[]+)?                             # issue title 
+        "?                                            # Trailing "
+        (\[(?P<args>.+?)\])?                          # [prop=value]
+        ''', re.IGNORECASE|re.VERBOSE)
+
+    def __init__(self, instance, db, arguments={}):
         self.instance = instance
         self.db = db
+        self.arguments = arguments
+        self.mailer = Mailer(instance.config)
+
+        # should we trap exceptions (normal usage) or pass them through
+        # (for testing)
+        self.trapExceptions = 1
+
+    def do_pipe(self):
+        """ Read a message from standard input and pass it to the mail handler.
+
+            Read into an internal structure that we can seek on (in case
+            there's an error).
+
+            XXX: we may want to read this into a temporary file instead...
+        """
+        s = cStringIO.StringIO()
+        s.write(sys.stdin.read())
+        s.seek(0)
+        self.main(s)
+        return 0
+
+    def do_mailbox(self, filename):
+        """ Read a series of messages from the specified unix mailbox file and
+            pass each to the mail handler.
+        """
+        # open the spool file and lock it
+        import fcntl
+        # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
+        if hasattr(fcntl, 'LOCK_EX'):
+            FCNTL = fcntl
+        else:
+            import FCNTL
+        f = open(filename, 'r+')
+        fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
+
+        # handle and clear the mailbox
+        try:
+            from mailbox import UnixMailbox
+            mailbox = UnixMailbox(f, factory=Message)
+            # grab one message
+            message = mailbox.next()
+            while message:
+                # handle this message
+                self.handle_Message(message)
+                message = mailbox.next()
+            # nuke the file contents
+            os.ftruncate(f.fileno(), 0)
+        except:
+            import traceback
+            traceback.print_exc()
+            return 1
+        fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
+        return 0
+
+    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
+        try:
+            if not user:
+                user = raw_input(_('User: '))
+            if not password:
+                password = getpass.getpass()
+        except (KeyboardInterrupt, EOFError):
+            # Ctrl C or D maybe also Ctrl Z under Windows.
+            print "\nAborted by user."
+            return 1
+
+        # open a connection to the server and retrieve all messages
+        try:
+            server = poplib.POP3(server)
+        except socket.error, message:
+            print "POP server error:", message
+            return 1
+        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 
+            # [ pop response e.g. '+OK 459 octets',
+            #   [ array of message lines ],
+            #   number of octets ]
+            lines = server.retr(i)[1]
+            s = cStringIO.StringIO('\n'.join(lines))
+            s.seek(0)
+            self.handle_Message(Message(s))
+            # delete the message
+            server.dele(i)
+
+        # quit the server to commit changes.
+        server.quit()
+        return 0
 
     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
+        """Handle an RFC822 Message
 
         Handle the Message object by calling handle_message() and then cope
         with any errors raised by handle_message.
         This method's job is to make that call and handle any
         errors in a sane manner. It should be replaced if you wish to
         handle errors in a different manner.
-        '''
+        """
         # 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:
@@ -155,7 +310,7 @@ class MailGW:
                 m = ['']
                 m.append('\n\nMail Gateway Help\n=================')
                 m.append(fulldoc)
-                m = self.bounce_message(message, sendto, m,
+                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
@@ -165,16 +320,20 @@ class MailGW:
                 m.append(str(value))
                 m.append('\n\nMail Gateway Help\n=================')
                 m.append(fulldoc)
-                m = self.bounce_message(message, sendto, m)
-            except UnAuthorized, value:
+                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))
-                m = self.bounce_message(message, sendto, m)
+                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
-                sendto = [sendto[0][1], self.instance.ADMIN_EMAIL]
+                # 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')
@@ -184,112 +343,129 @@ class MailGW:
                 import traceback
                 traceback.print_exc(None, s)
                 m.append(s.getvalue())
-                m = self.bounce_message(message, sendto, m)
+                self.mailer.bounce_message(message, sendto, m)
         else:
             # very bad-looking message - we don't even know who sent it
-            sendto = [self.instance.ADMIN_EMAIL]
+            # XXX we should use a log file here...
+            sendto = [self.instance.config.ADMIN_EMAIL]
             m = ['Subject: badly formed message from mail gateway']
             m.append('')
             m.append('The mail gateway retrieved a message which has no From:')
             m.append('line, indicating that it is corrupt. Please check your')
             m.append('mail gateway source. Failed message is attached.')
             m.append('')
-            m = self.bounce_message(message, sendto, m,
+            self.mailer.bounce_message(message, sendto, m,
                 subject='Badly formed message from mail gateway')
 
-        # now send the message
-        if SENDMAILDEBUG:
-            open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
-                self.instance.ADMIN_EMAIL, ', '.join(sendto), m.getvalue()))
+    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:
-            try:
-                smtp = smtplib.SMTP(self.instance.MAILHOST)
-                smtp.sendmail(self.instance.ADMIN_EMAIL, sendto, m.getvalue())
-            except socket.error, value:
-                raise MailGWError, "Couldn't send error email: "\
-                    "mailhost %s"%value
-            except smtplib.SMTPException, value:
-                raise MailGWError, "Couldn't send error email: %s"%value
-
-    def bounce_message(self, message, sendto, error,
-            subject='Failed issue tracker submission'):
-        ''' create a message that explains the reason for the failed
-            issue submission to the author and attach the original
-            message.
-        '''
-        msg = cStringIO.StringIO()
-        writer = MimeWriter.MimeWriter(msg)
-        writer.addheader('Subject', subject)
-        writer.addheader('From', '%s <%s>'% (self.instance.INSTANCE_NAME,
-                                            self.instance.ISSUE_TRACKER_EMAIL))
-        writer.addheader('To', ','.join(sendto))
-        writer.addheader('MIME-Version', '1.0')
-        part = writer.startmultipartbody('mixed')
-        part = writer.nextpart()
-        body = part.startbody('text/plain')
-        body.write('\n'.join(error))
-
-        # reconstruct the original message
-        m = cStringIO.StringIO()
-        w = MimeWriter.MimeWriter(m)
-        # default the content_type, just in case...
-        content_type = 'text/plain'
-        # add the headers except the content-type
-        for header in message.headers:
-            header_name = header.split(':')[0]
-            if header_name.lower() == 'content-type':
-                content_type = message.getheader(header_name)
-            elif message.getheader(header_name):
-                w.addheader(header_name, message.getheader(header_name))
-        # now attach the message body
-        body = w.startbody(content_type)
-        try:
-            message.rewindbody()
-        except IOError:
-            body.write("*** couldn't include message body: read from pipe ***")
+            # 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:
-            body.write(message.fp.read())
-
-        # attach the original message to the returned message
-        part = writer.nextpart()
-        part.addheader('Content-Disposition','attachment')
-        part.addheader('Content-Description','Message you sent')
-        part.addheader('Content-Transfer-Encoding', '7bit')
-        body = part.startbody('message/rfc822')
-        body.write(m.getvalue())
-
-        writer.lastpart()
-        return msg
+            # 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 not subject:
+            raise MailUsageError, '''
+Emails to Roundup trackers must include a Subject: line!
+'''
+
+        if subject.strip().lower() == 'help':
             raise MailUsageHelp
 
-        m = subject_re.match(subject)
+        m = self.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, check if this a registration confirmation email
+                # or fallback on the default class
+                otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
+                otk = otk_re.search(m.group('title'))
+                if otk:
+                    self.db.confirm_registration(otk.group('otk'))
+                    subject = 'Your registration to %s is complete' % \
+                              self.instance.config.TRACKER_NAME
+                    sendto = [message.getheader('from')]
+                    self.mailer.standard_message(sendto, subject, '') 
+                    return
+                elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
+                         self.instance.config.MAIL_DEFAULT_CLASS:
+                    classname = self.instance.config.MAIL_DEFAULT_CLASS
+                else:
+                    # fail
+                    m = None
+
         if not m:
-            raise MailUsageError, '''
+            raise MailUsageError, """
 The message you sent to roundup did not contain a properly formed subject
 line. The subject must contain a class name or designator to indicate the
-"topic" of the message. For example:
+'topic' of the message. For example:
     Subject: [issue] This is a new issue
-      - this will create a new issue in the tracker with the title "This is
-        a new issue".
+      - this will create a new issue in the tracker with the title 'This is
+        a new issue'.
     Subject: [issue1234] This is a followup to issue 1234
       - this will append the message's contents to the existing issue 1234
         in the tracker.
 
-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:
@@ -311,8 +487,13 @@ Subject was: "%s"
         else:
             title = ''
 
+        # strip off the quotes that dumb emailers put around the subject, like
+        #      Re: "[issue1] bla blah"
+        if m.group('quote') and title.endswith('"'):
+            title = title[:-1]
+
         # 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,130 +502,111 @@ 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
-        properties = cl.getprops()
-        props = {}
-
-        # handle the args
-        args = m.group('args')
-        if args:
-            for prop in string.split(args, ';'):
-                # extract the property name and value
-                try:
-                    key, 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)
-
-                # ensure it's a valid property name
-                key = key.strip()
-                try:
-                    proptype =  properties[key]
-                except KeyError:
-                    raise MailUsageError, '''
-Subject argument list refers to an invalid property: "%s"
-
-Subject was: "%s"
-'''%(key, subject)
-
-                # convert the string value to a real property value
-                if isinstance(proptype, hyperdb.String):
-                    props[key] = value.strip()
-                if isinstance(proptype, hyperdb.Password):
-                    props[key] = password.Password(value.strip())
-                elif isinstance(proptype, hyperdb.Date):
-                    try:
-                        props[key] = date.Date(value.strip())
-                    except ValueError, message:
-                        raise UsageError, '''
-Subject argument list contains an invalid date for %s.
+        # 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.
 
-Error was: %s
 Subject was: "%s"
-'''%(key, message, subject)
-                elif isinstance(proptype, hyperdb.Interval):
-                    try:
-                        props[key] = date.Interval(value) # no strip needed
-                    except ValueError, message:
-                        raise UsageError, '''
-Subject argument list contains an invalid date interval for %s.
+'''%(nodeid, subject)
 
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
-                elif isinstance(proptype, hyperdb.Link):
-                    linkcl = self.db.classes[proptype.classname]
-                    propkey = linkcl.labelprop(default_to_id=1)
-                    try:
-                        props[key] = linkcl.lookup(value)
-                    except KeyError, message:
+        # 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, '''
-Subject argument list contains an invalid value for %s.
-
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
-                elif isinstance(proptype, hyperdb.Multilink):
-                    # get the linked class
-                    linkcl = self.db.classes[proptype.classname]
-                    propkey = linkcl.labelprop(default_to_id=1)
-                    for item in value.split(','):
-                        item = item.strip()
-                        try:
-                            item = linkcl.lookup(item)
-                        except KeyError, message:
-                            raise MailUsageError, '''
-Subject argument list contains an invalid value for %s.
-
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
-                        if props.has_key(key):
-                            props[key].append(item)
-                        else:
-                            props[key] = [item]
+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
         #
-
-        # Don't create users if ANONYMOUS_REGISTER is denied
-        if self.instance.ANONYMOUS_REGISTER == 'deny':
+        # Don't create users if anonymous isn't allowed to register
+        create = 1
+        anonid = self.db.user.lookup('anonymous')
+        if not self.db.security.hasPermission('Email Registration', anonid):
             create = 0
-        else:
-            create = 1
-        author = self.db.uidFromAddress(message.getaddrlist('from')[0],
+
+        # 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)
+
+        # if we're not recognised, and we don't get added as a user, then we
+        # must be anonymous
         if not author:
-            raise UnAuthorized, '''
+            author = anonid
+
+        # make sure the author has permission to use the email interface
+        if not self.db.security.hasPermission('Email Access', author):
+            if author == anonid:
+                # we're anonymous and we need to be a registered user
+                raise Unauthorized, '''
 You are not a registered user.
 
 Unknown address: %s
 '''%message.getaddrlist('from')[0][1]
+            else:
+                # we're registered and we're _still_ not allowed access
+                raise Unauthorized, 'You are not permitted to access '\
+                    'this tracker.'
+
+        # make sure they're allowed to edit this class of information
+        if not self.db.security.hasPermission('Edit', author, classname):
+            raise Unauthorized, 'You are not permitted to edit %s.'%classname
 
         # the author may have been created - make sure the change is
         # committed before we reopen the database
         self.db.commit()
-            
+
         # reopen the database as the author
         username = self.db.user.get(author, 'username')
+        self.db.close()
         self.db = self.instance.open(username)
 
         # re-get the class with the new database connection
@@ -452,12 +614,43 @@ Unknown address: %s
 
         # now update the recipients list
         recipients = []
-        tracker_email = self.instance.ISSUE_TRACKER_EMAIL.lower()
+        tracker_email = self.instance.config.TRACKER_EMAIL.lower()
         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
             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 = uidFromAddress(self.db, recipient, create, **user_props)
+
+            # if all's well, add the recipient to the list
+            if recipient:
+                recipients.append(recipient)
+
+        #
+        # handle the subject argument list
+        #
+        # figure what the properties of this Class are
+        properties = cl.getprops()
+        props = {}
+        args = m.group('args')
+        if args:
+            errors, props = setPropArrayFromString(self, cl, args, nodeid)
+            # 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
+
+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
@@ -467,13 +660,39 @@ Unknown address: %s
         # generate a messageid if there isn't one
         if not messageid:
             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
-                classname, nodeid, self.instance.MAIL_DOMAIN)
+                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()
@@ -486,32 +705,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
-                    # BUG (in code or comment) only add the first one. 
-                    if content is None:
-                        # try name on Content-Type
-                        # maybe add name to non text content ?
-                        name = part.getparam('name')
-                        # assume first part is the mail
-                        encoding = part.getencoding()
-                        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()
-                        content = data
-                    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
@@ -520,21 +715,32 @@ Unknown address: %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')
+                    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
-                    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, '''
@@ -553,8 +759,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
@@ -568,24 +773,18 @@ not find a text/plain part to use.
 '''
 
         else:
-            encoding = message.getencoding()
-            if encoding == 'base64':
-                # BUG: is base64 really used for text encoding or
-                # are we inserting zip files here. 
-                data = binascii.a2b_base64(message.fp.read())
-            elif encoding == 'quoted-printable':
-                # the quopri module wants to work with files
-                decoded = cStringIO.StringIO()
-                quopri.decode(message.fp, decoded)
-                data = decoded.getvalue()
-            elif encoding == 'uuencoded':
-                data = binascii.a2b_uu(message.fp.read())
-            else:
-                # take it as text
-                data = message.fp.read()
-            content = data
+            content = self.get_part_data_decoded(message) 
  
-        summary, content = parseContent(content)
+        # figure how much we should muck around with the email body
+        keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
+            'no') == 'yes'
+        keep_body = getattr(self.instance.config, '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)
+        content = content.strip()
 
         # 
         # handle the attachments
@@ -595,150 +794,258 @@ 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))
-
-        #
-        # now handle the db stuff
-        #
+                content=data, **file_props))
+        # attach the files to the issue
         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)
+            # 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:
             message_id = self.db.msg.create(author=author,
                 recipients=recipients, date=date.Date('.'), summary=summary,
                 content=content, files=files, messageid=messageid,
-                inreplyto=inreplyto)
-            try:
-                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.
+                inreplyto=inreplyto, **msg_props)
 
-Subject was: "%s"
-'''%(nodeid, subject)
-            messages.append(message_id)
-            props['messages'] = messages
+            # attach the message to the node
+            if nodeid:
+                # add the message to the node's list
+                messages = cl.get(nodeid, 'messages')
+                messages.append(message_id)
+                props['messages'] = messages
+            else:
+                # pre-load the messages list
+                props['messages'] = [message_id]
 
-            # now apply the changes
-            try:
+        #
+        # 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)
-            except (TypeError, IndexError, ValueError), message:
-                raise MailUsageError, '''
+            else:
+                nodeid = cl.create(**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'):
+        # commit the changes to the DB
+        self.db.commit()
+
+        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:
-                    # determine the id of 'unread'
-                    unread_id = self.db.status.lookup('unread')
-                except KeyError:
-                    pass
+                    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:
-                    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
+                    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
+    '''
+    if len(users) > 1:
+        for user in users:
+            # make sure we don't match the anonymous or admin user
+            if userClass.get(user, 'username') in ('admin', 'anonymous'):
+                continue
+            # first valid match will do
+            return user
+        # well, I guess we have no choice
+        return user[0]
+    elif users:
+        return users[0]
+    return None
+
+
+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
+
+    # 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
+
+    # try to match the username to the address (for local
+    # submissions where the address is empty)
+    user = extractUserFromList(db.user, db.user.stringFind(username=address))
+
+    # couldn't match address or username, so create a new user
+    if create:
+        # generate a username
+        if '@' in address:
+            username = address.split('@')[0]
+        else:
+            username = address
+        trying = username
+        n = 0
+        while 1:
             try:
-                nodeid = cl.create(**props)
-            except (TypeError, IndexError, ValueError), message:
-                raise MailUsageError, '''
-There was a problem with the message you sent:
-   %s
-'''%message
+                # 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
 
-            # 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*$')):
+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_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 the first
-    non-quoting section becomes the summary of the message. 
+        Sections where the second and all subsequent lines begin with a ">"
+        or "|" character are considered "quoting sections". The first line of
+        the first non-quoting section becomes the summary of the message. 
+
+        If keep_citations is true, then we keep the "quoting sections" in the
+        content.
+        If keep_body is true, we even keep the signature sections.
     '''
     # strip off leading carriage-returns / newlines
     i = 0
@@ -763,324 +1070,49 @@ def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
             # see if there's a response somewhere inside this section (ie.
             # no blank line between quoted message and response)
             for line in lines[1:]:
-                if line[0] not in '>|':
+                if line and 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:
-                # and while we're at it, use the first non-quoted bit as
-                # our summary
-                summary = line
             lines = lines[lines.index(line):]
             section = '\n'.join(lines)
+            # and while we're at it, use the first non-quoted bit as
+            # our summary
+            summary = section
 
         if not summary:
             # if we don't have our summary yet use the first line of this
             # section
-            summary = lines[0]
-        elif signature.match(lines[0]):
+            summary = section
+        elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
+            # lose any signature
+            break
+        elif original_msg.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)
 
-#
-# $Log: not supported by cvs2svn $
-# 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.56  2002/01/22 11:54:45  rochecompaan
-# Fixed status change in mail gateway.
-#
-# Revision 1.55  2002/01/21 10:05:47  rochecompaan
-# Feature:
-#  . the mail gateway now responds with an error message when invalid
-#    values for arguments are specified for link or multilink properties
-#  . modified unit test to check nosy and assignedto when specified as
-#    arguments
-#
-# Fixed:
-#  . fixed setting nosy as argument in subject line
-#
-# Revision 1.54  2002/01/16 09:14:45  grubert
-#  . if the attachment has no name, name it unnamed, happens with tnefs.
-#
-# Revision 1.53  2002/01/16 07:20:54  richard
-# simple help command for mailgw
-#
-# Revision 1.52  2002/01/15 00:12:40  richard
-# #503340 ] creating issue with [asignedto=p.ohly]
-#
-# Revision 1.51  2002/01/14 02:20:15  richard
-#  . changed all config accesses so they access either the instance or the
-#    config attriubute on the db. This means that all config is obtained from
-#    instance_config instead of the mish-mash of classes. This will make
-#    switching to a ConfigParser setup easier too, I hope.
-#
-# At a minimum, this makes migration a _little_ easier (a lot easier in the
-# 0.5.0 switch, I hope!)
-#
-# Revision 1.50  2002/01/11 22:59:01  richard
-#  . #502342 ] pipe interface
-#
-# Revision 1.49  2002/01/10 06:19:18  richard
-# followup lines directly after a quoted section were being eaten.
-#
-# Revision 1.48  2002/01/08 04:12:05  richard
-# Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
-#
-# Revision 1.47  2002/01/02 02:32:38  richard
-# ANONYMOUS_ACCESS -> ANONYMOUS_REGISTER
-#
-# Revision 1.46  2002/01/02 02:31:38  richard
-# Sorry for the huge checkin message - I was only intending to implement #496356
-# but I found a number of places where things had been broken by transactions:
-#  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
-#    for _all_ roundup-generated smtp messages to be sent to.
-#  . the transaction cache had broken the roundupdb.Class set() reactors
-#  . newly-created author users in the mailgw weren't being committed to the db
-#
-# Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
-# on when I found that stuff :):
-#  . #496356 ] Use threading in messages
-#  . detectors were being registered multiple times
-#  . added tests for mailgw
-#  . much better attaching of erroneous messages in the mail gateway
-#
-# Revision 1.45  2001/12/20 15:43:01  rochecompaan
-# Features added:
-#  .  Multilink properties are now displayed as comma separated values in
-#     a textbox
-#  .  The add user link is now only visible to the admin user
-#  .  Modified the mail gateway to reject submissions from unknown
-#     addresses if ANONYMOUS_ACCESS is denied
-#
-# Revision 1.44  2001/12/18 15:30:34  rochecompaan
-# Fixed bugs:
-#  .  Fixed file creation and retrieval in same transaction in anydbm
-#     backend
-#  .  Cgi interface now renders new issue after issue creation
-#  .  Could not set issue status to resolved through cgi interface
-#  .  Mail gateway was changing status back to 'chatting' if status was
-#     omitted as an argument
-#
-# Revision 1.43  2001/12/15 19:39:01  rochecompaan
-# Oops.
-#
-# Revision 1.42  2001/12/15 19:24:39  rochecompaan
-#  . Modified cgi interface to change properties only once all changes are
-#    collected, files created and messages generated.
-#  . Moved generation of change note to nosyreactors.
-#  . We now check for changes to "assignedto" to ensure it's added to the
-#    nosy list.
-#
-# Revision 1.41  2001/12/10 00:57:38  richard
-# From CHANGES:
-#  . Added the "display" command to the admin tool - displays a node's values
-#  . #489760 ] [issue] only subject
-#  . fixed the doc/index.html to include the quoting in the mail alias.
-#
-# Also:
-#  . fixed roundup-admin so it works with transactions
-#  . disabled the back_anydbm module if anydbm tries to use dumbdbm
-#
-# Revision 1.40  2001/12/05 14:26:44  rochecompaan
-# Removed generation of change note from "sendmessage" in roundupdb.py.
-# The change note is now generated when the message is created.
-#
-# Revision 1.39  2001/12/02 05:06:16  richard
-# . We now use weakrefs in the Classes to keep the database reference, so
-#   the close() method on the database is no longer needed.
-#   I bumped the minimum python requirement up to 2.1 accordingly.
-# . #487480 ] roundup-server
-# . #487476 ] INSTALL.txt
-#
-# I also cleaned up the change message / post-edit stuff in the cgi client.
-# There's now a clearly marked "TODO: append the change note" where I believe
-# the change note should be added there. The "changes" list will obviously
-# have to be modified to be a dict of the changes, or somesuch.
-#
-# More testing needed.
-#
-# Revision 1.38  2001/12/01 07:17:50  richard
-# . We now have basic transaction support! Information is only written to
-#   the database when the commit() method is called. Only the anydbm
-#   backend is modified in this way - neither of the bsddb backends have been.
-#   The mail, admin and cgi interfaces all use commit (except the admin tool
-#   doesn't have a commit command, so interactive users can't commit...)
-# . Fixed login/registration forwarding the user to the right page (or not,
-#   on a failure)
-#
-# Revision 1.37  2001/11/28 21:55:35  richard
-#  . login_action and newuser_action return values were being ignored
-#  . Woohoo! Found that bloody re-login bug that was killing the mail
-#    gateway.
-#  (also a minor cleanup in hyperdb)
-#
-# Revision 1.36  2001/11/26 22:55:56  richard
-# Feature:
-#  . Added INSTANCE_NAME to configuration - used in web and email to identify
-#    the instance.
-#  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
-#    signature info in e-mails.
-#  . Some more flexibility in the mail gateway and more error handling.
-#  . Login now takes you to the page you back to the were denied access to.
-#
-# Fixed:
-#  . Lots of bugs, thanks Roché and others on the devel mailing list!
-#
-# Revision 1.35  2001/11/22 15:46:42  jhermann
-# Added module docstrings to all modules.
-#
-# Revision 1.34  2001/11/15 10:24:27  richard
-# handle the case where there is no file attached
-#
-# Revision 1.33  2001/11/13 21:44:44  richard
-#  . re-open the database as the author in mail handling
-#
-# Revision 1.32  2001/11/12 22:04:29  richard
-# oops, left debug in there
-#
-# Revision 1.31  2001/11/12 22:01:06  richard
-# Fixed issues with nosy reaction and author copies.
-#
-# Revision 1.30  2001/11/09 22:33:28  richard
-# More error handling fixes.
-#
-# Revision 1.29  2001/11/07 05:29:26  richard
-# Modified roundup-mailgw so it can read e-mails from a local mail spool
-# file. Truncates the spool file after parsing.
-# Fixed a couple of small bugs introduced in roundup.mailgw when I started
-# the popgw.
-#
-# Revision 1.28  2001/11/01 22:04:37  richard
-# Started work on supporting a pop3-fetching server
-# Fixed bugs:
-#  . bug #477104 ] HTML tag error in roundup-server
-#  . bug #477107 ] HTTP header problem
-#
-# Revision 1.27  2001/10/30 11:26:10  richard
-# Case-insensitive match for ISSUE_TRACKER_EMAIL in address in e-mail.
-#
-# Revision 1.26  2001/10/30 00:54:45  richard
-# Features:
-#  . #467129 ] Lossage when username=e-mail-address
-#  . #473123 ] Change message generation for author
-#  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
-#
-# Revision 1.25  2001/10/28 23:22:28  richard
-# fixed bug #474749 ] Indentations lost
-#
-# Revision 1.24  2001/10/23 22:57:52  richard
-# Fix unread->chatting auto transition, thanks Roch'e
-#
-# Revision 1.23  2001/10/21 04:00:20  richard
-# MailGW now moves 'unread' to 'chatting' on receiving e-mail for an issue.
-#
-# Revision 1.22  2001/10/21 03:35:13  richard
-# bug #473125: Paragraph in e-mails
-#
-# Revision 1.21  2001/10/21 00:53:42  richard
-# bug #473130: Nosy list not set correctly
-#
-# Revision 1.20  2001/10/17 23:13:19  richard
-# Did a fair bit of work on the admin tool. Now has an extra command "table"
-# which displays node information in a tabular format. Also fixed import and
-# export so they work. Removed freshen.
-# Fixed quopri usage in mailgw from bug reports.
-#
-# Revision 1.19  2001/10/11 23:43:04  richard
-# Implemented the comma-separated printing option in the admin tool.
-# Fixed a typo (more of a vim-o actually :) in mailgw.
-#
-# Revision 1.18  2001/10/11 06:38:57  richard
-# Initial cut at trying to handle people responding to CC'ed messages that
-# create an issue.
-#
-# Revision 1.17  2001/10/09 07:25:59  richard
-# Added the Password property type. See "pydoc roundup.password" for
-# implementation details. Have updated some of the documentation too.
-#
-# Revision 1.16  2001/10/05 02:23:24  richard
-#  . roundup-admin create now prompts for property info if none is supplied
-#    on the command-line.
-#  . hyperdb Class getprops() method may now return only the mutable
-#    properties.
-#  . Login now uses cookies, which makes it a whole lot more flexible. We can
-#    now support anonymous user access (read-only, unless there's an
-#    "anonymous" user, in which case write access is permitted). Login
-#    handling has been moved into cgi_client.Client.main()
-#  . The "extended" schema is now the default in roundup init.
-#  . The schemas have had their page headings modified to cope with the new
-#    login handling. Existing installations should copy the interfaces.py
-#    file from the roundup lib directory to their instance home.
-#  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
-#    Ping - has been removed.
-#  . Fixed a whole bunch of places in the CGI interface where we should have
-#    been returning Not Found instead of throwing an exception.
-#  . Fixed a deviation from the spec: trying to modify the 'id' property of
-#    an item now throws an exception.
-#
-# Revision 1.15  2001/08/30 06:01:17  richard
-# Fixed missing import in mailgw :(
-#
-# Revision 1.14  2001/08/13 23:02:54  richard
-# Make the mail parser a little more robust.
-#
-# Revision 1.13  2001/08/12 06:32:36  richard
-# using isinstance(blah, Foo) now instead of isFooType
-#
-# Revision 1.12  2001/08/08 01:27:00  richard
-# Added better error handling to mailgw.
-#
-# Revision 1.11  2001/08/08 00:08:03  richard
-# oops ;)
-#
-# Revision 1.10  2001/08/07 00:24:42  richard
-# stupid typo
-#
-# Revision 1.9  2001/08/07 00:15:51  richard
-# Added the copyright/license notice to (nearly) all files at request of
-# Bizar Software.
-#
-# Revision 1.8  2001/08/05 07:06:07  richard
-# removed some print statements
-#
-# Revision 1.7  2001/08/03 07:18:22  richard
-# Implemented correct mail splitting (was taking a shortcut). Added unit
-# tests. Also snips signatures now too.
-#
-# Revision 1.6  2001/08/01 04:24:21  richard
-# mailgw was assuming certain properties existed on the issues being created.
-#
-# Revision 1.5  2001/07/29 07:01:39  richard
-# Added vim command to all source so that we don't get no steenkin' tabs :)
-#
-# Revision 1.4  2001/07/28 06:43:02  richard
-# Multipart message class has the getPart method now. Added some tests for it.
-#
-# Revision 1.3  2001/07/28 00:34:34  richard
-# Fixed some non-string node ids.
-#
-# Revision 1.2  2001/07/22 12:09:32  richard
-# Final commit of Grande Splite
-#
-#
+    # figure the summary - find the first sentence-ending punctuation or the
+    # first whole line, whichever is longest
+    sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
+    if sentence:
+        sentence = sentence.group(1)
+    else:
+        sentence = ''
+    first = eol.split(summary)[0]
+    summary = max(sentence, first)
+
+    # Now reconstitute the message content minus the bits we don't care
+    # about.
+    if not keep_body:
+        content = '\n\n'.join(l)
+
+    return summary, content
+
 # vim: set filetype=python ts=4 sw=4 et si