Code

Fix mailer (sf bug #817470) and add docstrings to prevent this from happening again.
[roundup.git] / roundup / mailgw.py
index 962d9b865e379772c49f1c53a9dc84d4512e8a0c..8ec27fe983d0e0689a8dc9c233bfbff7369cd855 100644 (file)
@@ -16,7 +16,7 @@
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 #
 
-'''
+"""
 An e-mail gateway for Roundup.
 
 Incoming messages are examined for multiple parts:
@@ -73,15 +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.108 2003-01-27 16:32:46 kedder 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, sys
-import traceback, MimeWriter
-import hyperdb, date, password
+import traceback, MimeWriter, rfc822
 
-import rfc2822
+from roundup import hyperdb, date, password, rfc2822
+from roundup.mailer import Mailer
 
 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
 
@@ -95,7 +95,7 @@ class MailUsageHelp(Exception):
     pass
 
 class MailLoop(Exception):
-    ''' We've seen this message before... '''
+    """ We've seen this message before... """
     pass
 
 class Unauthorized(Exception):
@@ -113,6 +113,26 @@ 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
+
 class Message(mimetools.Message):
     ''' subclass mimetools.Message so we can retrieve the parts of the
         message...
@@ -138,30 +158,43 @@ class Message(mimetools.Message):
 
     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)
  
-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:
+
+    # 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 = {}
+        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 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)
@@ -169,11 +202,16 @@ class MailGW:
         return 0
 
     def do_mailbox(self, filename):
-        ''' Read a series of messages from the specified unix mailbox file and
+        """ 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
+        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)
 
@@ -196,7 +234,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
@@ -216,8 +259,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 
@@ -241,14 +287,14 @@ class MailGW:
         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')
@@ -264,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
@@ -274,13 +320,13 @@ class MailGW:
                 m.append(str(value))
                 m.append('\n\nMail Gateway Help\n=================')
                 m.append(fulldoc)
-                m = self.bounce_message(message, sendto, m)
+                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
@@ -297,7 +343,7 @@ 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
             # XXX we should use a log file here...
@@ -308,64 +354,9 @@ class MailGW:
             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, '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.sendmail(self.instance.config.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('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; charset=utf-8')
-        body.write('\n'.join(error))
-
-        # attach the original message to the returned message
-        part = writer.nextpart()
-        part.addheader('Content-Disposition','attachment')
-        part.addheader('Content-Description','Message you sent')
-        body = part.startbody('text/plain')
-        for header in message.headers:
-            body.write(header)
-        body.write('\n')
-        try:
-            message.rewindbody()
-        except IOError, message:
-            body.write("*** couldn't include message body: %s ***"%message)
-        else:
-            body.write(message.fp.read())
-
-        writer.lastpart()
-        return msg
-
     def get_part_data_decoded(self,part):
         encoding = part.getencoding()
         data = None
@@ -426,38 +417,53 @@ class MailGW:
         # handle the subject line
         subject = message.getheader('subject', '')
 
+        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, fallback on the default
-                if hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
-                        self.instance.config.MAIL_DEFAULT_CLASS:
+                # 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 class
         try:
@@ -515,7 +521,6 @@ 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.
@@ -623,11 +628,6 @@ Unknown address: %s
             if recipient:
                 recipients.append(recipient)
 
-        #
-        # XXX extract the args NOT USED WHY -- rouilj
-        #
-        subject_args = m.group('args')
-
         #
         # handle the subject argument list
         #
@@ -647,6 +647,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
         #
@@ -713,6 +718,7 @@ Subject was: "%s"
                 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
@@ -730,7 +736,7 @@ Subject was: "%s"
                     if not name:
                         disp = part.getheader('content-disposition', None)
                         if disp:
-                            name = disp.getparam('filename')
+                            name = getparam(disp, 'filename')
                             if name:
                                 name = name.strip()
                     # this is just an attachment
@@ -778,6 +784,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
@@ -788,6 +795,16 @@ not find a text/plain part to use.
                 name = "unnamed"
             files.append(self.db.file.create(type=mime_type, name=name,
                 content=data, **file_props))
+        # attach the files to the issue
+        if nodeid:
+            # extend the existing files list
+            fileprop = cl.get(nodeid, 'files')
+            fileprop.extend(files)
+            props['files'] = fileprop
+        else:
+            # pre-load the files list
+            props['files'] = files
+
 
         # 
         # create the message if there's a message body (content)
@@ -808,10 +825,6 @@ 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
         #
@@ -946,7 +959,7 @@ def setPropArrayFromString(self, cl, propString, nodeid = None):
             props[propname] = value.lower() in ('yes', 'true', 'on', '1')
         elif isinstance(proptype, hyperdb.Number):
             value = value.strip()
-            props[propname] = int(value)
+            props[propname] = float(value)
     return errors, props
 
 
@@ -978,14 +991,16 @@ def uidFromAddress(db, address, create=1, **user_props):
 
     # 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)
@@ -993,8 +1008,26 @@ def uidFromAddress(db, address, create=1, **user_props):
 
     # couldn't match address or username, so create a new user
     if create:
-        return db.user.create(username=address, address=address,
+        # 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