Code

Fix PGP implementation -- the pyme API has changed significantly since
[roundup.git] / roundup / mailgw.py
index 922dc12656a8d6dafc601aba82223b97fad1a32c..d269554f8783216a05f51de73ed062c15fa28ca3 100644 (file)
@@ -92,7 +92,7 @@ from roundup.i18n import _
 from roundup.hyperdb import iter_roles
 
 try:
-    import pyme, pyme.core, pyme.gpgme
+    import pyme, pyme.core, pyme.constants, pyme.constants.sigsum
 except ImportError:
     pyme = None
 
@@ -156,39 +156,31 @@ def gpgh_key_getall(key, attr):
     ''' return list of given attribute for all uids in
         a key
     '''
-    u = key.uids
-    while u:
+    for u in key.uids:
         yield getattr(u, attr)
-        u = u.next
 
-def gpgh_sigs(sig):
-    ''' more pythonic iteration over GPG signatures '''
-    while sig:
-        yield sig
-        sig = sig.next
-
-def check_pgp_sigs(sig, gpgctx, author):
+def check_pgp_sigs(sigs, gpgctx, author):
     ''' Theoretically a PGP message can have several signatures. GPGME
-        returns status on all signatures in a linked list. Walk that
-        linked list looking for the author's signature
+        returns status on all signatures in a list. Walk that list
+        looking for the author's signature
     '''
-    for sig in gpgh_sigs(sig):
+    for sig in sigs:
         key = gpgctx.get_key(sig.fpr, False)
         # we really only care about the signature of the user who
         # submitted the email
         if key and (author in gpgh_key_getall(key, 'email')):
-            if sig.summary & pyme.gpgme.GPGME_SIGSUM_VALID:
+            if sig.summary & pyme.constants.sigsum.VALID:
                 return True
             else:
                 # try to narrow down the actual problem to give a more useful
                 # message in our bounce
-                if sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_MISSING:
+                if sig.summary & pyme.constants.sigsum.KEY_MISSING:
                     raise MailUsageError, \
                         _("Message signed with unknown key: %s") % sig.fpr
-                elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_EXPIRED:
+                elif sig.summary & pyme.constants.sigsum.KEY_EXPIRED:
                     raise MailUsageError, \
                         _("Message signed with an expired key: %s") % sig.fpr
-                elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_REVOKED:
+                elif sig.summary & pyme.constants.sigsum.KEY_REVOKED:
                     raise MailUsageError, \
                         _("Message signed with a revoked key: %s") % sig.fpr
                 else:
@@ -247,6 +239,22 @@ class Message(mimetools.Message):
             parts.append(part)
         return parts
 
+    def _decode_header_to_utf8(self, hdr):
+        l = []
+        prev_encoded = False
+        for part, encoding in decode_header(hdr):
+            if encoding:
+                part = part.decode(encoding)
+            # RFC 2047 specifies that between encoded parts spaces are
+            # swallowed while at the borders from encoded to non-encoded
+            # or vice-versa we must preserve a space. Multiple adjacent
+            # non-encoded parts should not occur.
+            if l and prev_encoded != bool(encoding):
+                l.append(' ')
+            prev_encoded = bool(encoding)
+            l.append(part)
+        return ''.join([s.encode('utf-8') for s in l])
+
     def getheader(self, name, default=None):
         hdr = mimetools.Message.getheader(self, name, default)
         # TODO are there any other False values possible?
@@ -257,24 +265,13 @@ class Message(mimetools.Message):
             return ''
         if hdr:
             hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
-        # historically this method has returned utf-8 encoded string
-        l = []
-        for part, encoding in decode_header(hdr):
-            if encoding:
-                part = part.decode(encoding)
-            l.append(part)
-        return ''.join([s.encode('utf-8') for s in l])
+        return self._decode_header_to_utf8(hdr)
 
     def getaddrlist(self, name):
         # overload to decode the name part of the address
         l = []
         for (name, addr) in mimetools.Message.getaddrlist(self, name):
-            p = []
-            for part, encoding in decode_header(name):
-                if encoding:
-                    part = part.decode(encoding)
-                p.append(part)
-            name = ''.join([s.encode('utf-8') for s in p])
+            name = self._decode_header_to_utf8(name)
             l.append((name, addr))
         return l
 
@@ -506,7 +503,6 @@ class Message(mimetools.Message):
             raise MailUsageError, \
                 _("No PGP signature found in message.")
 
-        context = pyme.core.Context()
         # msg.getbody() is skipping over some headers that are
         # required to be present for verification to succeed so
         # we'll do this by hand
@@ -521,6 +517,7 @@ class Message(mimetools.Message):
         msg_data = pyme.core.Data(canonical_msg)
         sig_data = pyme.core.Data(sig.getbody())
 
+        context = pyme.core.Context()
         context.op_verify(sig_data, msg_data, None)
 
         # check all signatures for validity
@@ -549,6 +546,7 @@ class parsedMessage:
         self.nodeid = None
         self.author = None
         self.recipients = None
+        self.msg_props = {}
         self.props = None
         self.content = None
         self.attachments = None
@@ -856,7 +854,7 @@ Unknown address: %(from_address)s
                     'You are not permitted to access this tracker.')
         self.author = author
 
-    def check_node_permissions(self):
+    def check_permissions(self):
         ''' Check if the author has permission to edit or create this
             class of node
         '''
@@ -989,7 +987,7 @@ Subject was: "%(subject)s"
             """
             if self.config.PGP_ROLES:
                 return self.db.user.has_role(self.author,
-                    iter_roles(self.config.PGP_ROLES))
+                    *iter_roles(self.config.PGP_ROLES))
             else:
                 return True
 
@@ -1052,13 +1050,14 @@ encrypted.""")
                     'You are not permitted to add files to %(classname)s.'
                     ) % self.__dict__
 
+            self.msg_props['files'] = files
             if self.nodeid:
                 # extend the existing files list
                 fileprop = self.cl.get(self.nodeid, 'files')
                 fileprop.extend(files)
                 files = fileprop
 
-        self.props['files'] = files
+            self.props['files'] = files
 
     def create_msg(self):
         ''' Create msg containing all the relevant information from the message
@@ -1066,6 +1065,7 @@ encrypted.""")
         if not self.properties.has_key('messages'):
             return
         msg_props = self.mailgw.get_class_arguments('msg')
+        self.msg_props.update (msg_props)
         
         # Get the message ids
         inreplyto = self.message.getheader('in-reply-to') or ''
@@ -1093,8 +1093,8 @@ not find a text/plain part to use.
             try:
                 message_id = self.db.msg.create(author=self.author,
                     recipients=self.recipients, date=date.Date('.'),
-                    summary=summary, content=content, files=self.props['files'],
-                    messageid=messageid, inreplyto=inreplyto, **msg_props)
+                    summary=summary, content=content,
+                    messageid=messageid, inreplyto=inreplyto, **self.msg_props)
             except exceptions.Reject, error:
                 raise MailUsageError, _("""
 Mail message was rejected by a detector.
@@ -1147,6 +1147,71 @@ There was a problem with the message you sent:
 
         return self.nodeid
 
+        # 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
+#            issue_re = config['MAILGW_ISSUE_ADDRESS_RE']
+#            if issue_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
+
+    # Default sequence of methods to be called on message. Use this for
+    # easier override of the default message processing
+    # list consists of tuples (method, return_if_true), the parsing
+    # returns if the return_if_true flag is set for a method *and* the
+    # method returns something that evaluates to True.
+    method_list = [
+        # Filter out messages to ignore
+        (handle_ignore, False),
+        # Check for usage/help requests
+        (handle_help, False),
+        # Check if the subject line is valid
+        (check_subject, False),
+        # get importants parts from subject
+        (parse_subject, False),
+        # check for registration OTK
+        (rego_confirm, True),
+        # get the classname
+        (get_classname, False),
+        # get the optional nodeid:
+        (get_nodeid, False),
+        # Determine who the author is:
+        (get_author_id, False),
+        # allowed to edit or create this class?
+        (check_permissions, False),
+        # author may have been created:
+        # commit author to database and re-open as author
+        (commit_and_reopen_as_author, False),
+        # Get the recipients list
+        (get_recipients, False),
+        # get the new/updated node props
+        (get_props, False),
+        # Handle PGP signed or encrypted messages
+        (get_pgp_message, False),
+        # extract content and attachments from message body:
+        (get_content_and_attachments, False),
+        # put attachments into files linked to the issue:
+        (create_files, False),
+        # create the message if there's a message body (content):
+        (create_msg, False),
+    ]
+
+
+    def parse (self):
+        for method, flag in self.method_list:
+            ret = method(self)
+            if flag and ret:
+                return
+        # perform the node change / create:
+        return self.create_node()
 
 
 class MailGW:
@@ -1362,6 +1427,7 @@ class MailGW:
         # in some rare cases, a particularly stuffed-up e-mail will make
         # its way into here... try to handle it gracefully
 
+        self.parsed_message = None
         sendto = message.getaddrlist('resent-from')
         if not sendto:
             sendto = message.getaddrlist('from')
@@ -1451,77 +1517,8 @@ class MailGW:
         The following code expects an opened database and a try/finally
         that closes the database.
         '''
-        parsed_message = self.parsed_message_class(self, message)
-
-        # Filter out messages to ignore
-        parsed_message.handle_ignore()
-        
-        # Check for usage/help requests
-        parsed_message.handle_help()
-        
-        # Check if the subject line is valid
-        parsed_message.check_subject()
-
-        # XXX Don't enable. This doesn't work yet.
-        # XXX once this works it should be moved to parsedMessage class
-#  "[^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
-#            issue_re = config['MAILGW_ISSUE_ADDRESS_RE']
-#            if issue_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
-
-        # Parse the subject line to get the importants parts
-        parsed_message.parse_subject()
-
-        # check for registration OTK
-        if parsed_message.rego_confirm():
-            return
-
-        # get the classname
-        parsed_message.get_classname()
-
-        # get the optional nodeid
-        parsed_message.get_nodeid()
-
-        # Determine who the author is
-        parsed_message.get_author_id()
-        
-        # make sure they're allowed to edit or create this class
-        parsed_message.check_node_permissions()
-
-        # author may have been created:
-        # commit author to database and re-open as author
-        parsed_message.commit_and_reopen_as_author()
-
-        # Get the recipients list
-        parsed_message.get_recipients()
-
-        # get the new/updated node props
-        parsed_message.get_props()
-
-        # Handle PGP signed or encrypted messages
-        parsed_message.get_pgp_message()
-
-        # extract content and attachments from message body
-        parsed_message.get_content_and_attachments()
-
-        # put attachments into files linked to the issue
-        parsed_message.create_files()
-        
-        # create the message if there's a message body (content)
-        parsed_message.create_msg()
-            
-        # perform the node change / create
-        nodeid = parsed_message.create_node()
+        self.parsed_message = self.parsed_message_class(self, message)
+        nodeid = self.parsed_message.parse ()
 
         # commit the changes to the DB
         self.db.commit()
@@ -1658,7 +1655,17 @@ def uidFromAddress(db, address, create=1, **user_props):
     props = db.user.getprops()
     if props.has_key('alternate_addresses'):
         users = db.user.filter(None, {'alternate_addresses': address})
-        user = extractUserFromList(db.user, users)
+        # We want an exact match of the email, not just a substring
+        # match. Otherwise e.g. support@example.com would match
+        # discuss-support@example.com which is not what we want.
+        found_users = []
+        for u in users:
+            alt = db.user.get(u, 'alternate_addresses').split('\n')
+            for a in alt:
+                if a.strip().lower() == address.lower():
+                    found_users.append(u)
+                    break
+        user = extractUserFromList(db.user, found_users)
         if user is not None:
             return user
 
@@ -1688,7 +1695,7 @@ def uidFromAddress(db, address, create=1, **user_props):
         try:
             return db.user.create(username=trying, address=address,
                 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
-                password=password.Password(password.generatePassword()),
+                password=password.Password(password.generatePassword(), config=db.config),
                 **user_props)
         except exceptions.Reject:
             return 0