Code

That's gadfly done, mostly. Things left:
[roundup.git] / roundup / mailgw.py
index a4664e1b9f5bcd1c9dd3cf35d37f4116708a1039..edbfb89254cb66621090fc246985b0dd8fb93583 100644 (file)
@@ -73,7 +73,7 @@ are calling the create() method to create a new node). If an auditor raises
 an exception, the original message is bounced back to the sender with the
 explanatory message given in the exception. 
 
-$Id: mailgw.py,v 1.71 2002-05-08 02:40:55 richard Exp $
+$Id: mailgw.py,v 1.81 2002-08-19 00:21:56 richard Exp $
 '''
 
 
@@ -93,9 +93,21 @@ class MailUsageError(ValueError):
 class MailUsageHelp(Exception):
     pass
 
-class UnAuthorized(Exception):
+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)
+
 class Message(mimetools.Message):
     ''' subclass mimetools.Message so we can retrieve the parts of the
         message...
@@ -128,6 +140,10 @@ class MailGW:
         self.instance = instance
         self.db = db
 
+        # should we trap exceptions (normal usage) or pass them through
+        # (for testing)
+        self.trapExceptions = 1
+
     def main(self, fp):
         ''' fp - the file from which to read the Message.
         '''
@@ -146,6 +162,8 @@ class MailGW:
         # 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:
@@ -166,7 +184,7 @@ class MailGW:
                 m.append('\n\nMail Gateway Help\n=================')
                 m.append(fulldoc)
                 m = self.bounce_message(message, sendto, m)
-            except UnAuthorized, value:
+            except Unauthorized, value:
                 # just inform the user that he is not authorized
                 sendto = [sendto[0][1]]
                 m = ['']
@@ -344,7 +362,7 @@ Subject was: "%s"
             title = ''
 
         # 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
@@ -353,128 +371,182 @@ 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
+        # 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.
+
+Subject was: "%s"
+'''%(nodeid, subject)
+
+        #
+        # extract the args
+        #
+        subject_args = m.group('args')
+
+        #
+        # handle the subject argument list
+        #
+        # figure what the properties of this Class are
         properties = cl.getprops()
         props = {}
-
-        # handle the args
         args = m.group('args')
         if args:
+            errors = []
             for prop in string.split(args, ';'):
                 # extract the property name and value
                 try:
-                    key, value = prop.split('=')
+                    propname, 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)
+                    errors.append('not of form [arg=value,'
+                        'value,...;arg=value,value...]')
+                    break
 
                 # ensure it's a valid property name
-                key = key.strip()
+                propname = propname.strip()
                 try:
-                    proptype =  properties[key]
+                    proptype =  properties[propname]
                 except KeyError:
-                    raise MailUsageError, '''
-Subject argument list refers to an invalid property: "%s"
-
-Subject was: "%s"
-'''%(key, subject)
+                    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[key] = value.strip()
+                    props[propname] = value.strip()
                 if isinstance(proptype, hyperdb.Password):
-                    props[key] = password.Password(value.strip())
+                    props[propname] = password.Password(value.strip())
                 elif isinstance(proptype, hyperdb.Date):
                     try:
-                        props[key] = date.Date(value.strip())
+                        props[propname] = date.Date(value.strip())
                     except ValueError, message:
-                        raise UsageError, '''
-Subject argument list contains an invalid date for %s.
-
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
+                        errors.append('contains an invalid date for '
+                            '%s.'%propname)
                 elif isinstance(proptype, hyperdb.Interval):
                     try:
-                        props[key] = date.Interval(value) # no strip needed
+                        props[propname] = date.Interval(value)
                     except ValueError, message:
-                        raise UsageError, '''
-Subject argument list contains an invalid date interval for %s.
-
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
+                        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[key] = linkcl.lookup(value)
+                        props[propname] = linkcl.lookup(value)
                     except KeyError, message:
-                        raise MailUsageError, '''
-Subject argument list contains an invalid value for %s.
-
-Error was: %s
-Subject was: "%s"
-'''%(key, message, subject)
+                        errors.append('"%s" is not a value for %s.'%(value,
+                            propname))
                 elif isinstance(proptype, hyperdb.Multilink):
                     # get the linked class
                     linkcl = self.db.classes[proptype.classname]
                     propkey = linkcl.labelprop(default_to_id=1)
+                    if nodeid:
+                        curvalue = cl.get(nodeid, propname)
+                    else:
+                        curvalue = []
+
+                    # handle each add/remove in turn
+                    # keep an extra list for all items that are
+                    # definitely in the new list (in case of e.g.
+                    # <propname>=A,+B, which should replace the old
+                    # list with A,B)
+                    set = 0
+                    newvalue = []
                     for item in value.split(','):
                         item = item.strip()
+
+                        # handle +/-
+                        remove = 0
+                        if item.startswith('-'):
+                            remove = 1
+                            item = item[1:]
+                        elif item.startswith('+'):
+                            item = item[1:]
+                        else:
+                            set = 1
+
+                        # look up the value
                         try:
                             item = linkcl.lookup(item)
                         except KeyError, message:
-                            raise MailUsageError, '''
-Subject argument list contains an invalid value for %s.
+                            errors.append('"%s" is not a value for %s.'%(item,
+                                propname))
+                            continue
+
+                        # perform the add/remove
+                        if remove:
+                            try:
+                                curvalue.remove(item)
+                            except ValueError:
+                                errors.append('"%s" is not currently in '
+                                    'for %s.'%(item, propname))
+                                continue
+                        else:
+                            newvalue.append(item)
+                            if item not in curvalue:
+                                curvalue.append(item)
+
+                    # that's it, set the new Multilink property value,
+                    # or overwrite it completely
+                    if set:
+                        props[propname] = newvalue
+                    else:
+                        props[propname] = curvalue
+                elif isinstance(proptype, hyperdb.Boolean):
+                    value = value.strip()
+                    props[propname] = value.lower() in ('yes', 'true', 'on', '1')
+                elif isinstance(proptype, hyperdb.Number):
+                    value = value.strip()
+                    props[propname] = int(value)
+
+            # 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
 
-Error was: %s
 Subject was: "%s"
-'''%(key, message, subject)
-                        if props.has_key(key):
-                            props[key].append(item)
-                        else:
-                            props[key] = [item]
+'''%(errors, subject)
 
         #
         # handle the users
         #
 
-        # Don't create users if ANONYMOUS_REGISTER_MAIL is denied
-        # ... fall back on ANONYMOUS_REGISTER if the other doesn't exist
+        # Don't create users if anonymous isn't allowed to register
         create = 1
-        if hasattr(self.instance, 'ANONYMOUS_REGISTER_MAIL'):
-            if self.instance.ANONYMOUS_REGISTER_MAIL == 'deny':
-                create = 0
-        elif self.instance.ANONYMOUS_REGISTER == 'deny':
+        anonid = self.db.user.lookup('anonymous')
+        if not self.db.security.hasPermission('Email Registration', anonid):
             create = 0
 
-        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)
+
+        # no author? means we're not author
         if not author:
-            raise UnAuthorized, '''
+            raise Unauthorized, '''
 You are not a registered user.
 
 Unknown address: %s
 '''%message.getaddrlist('from')[0][1]
 
+        # make sure the author has permission to use the email interface
+        if not self.db.security.hasPermission('Email Access', author):
+            raise Unauthorized, 'You are not permitted to access this tracker.'
+
         # the author may have been created - make sure the change is
         # committed before we reopen the database
         self.db.commit()
@@ -496,7 +568,7 @@ Unknown address: %s
 
             # look up the recipient - create if necessary (and we're
             # allowed to)
-            recipient = self.db.uidFromAddress(recipient, create)
+            recipient = uidFromAddress(self.db, recipient, create)
 
             # if all's well, add the recipient to the list
             if recipient:
@@ -604,8 +676,13 @@ not find a text/plain part to use.
         else:
             content = self.get_part_data_decoded(message) 
  
-        keep_citations = self.instance.EMAIL_KEEP_QUOTED_TEXT == 'yes'
-        keep_body = self.instance.EMAIL_LEAVE_BODY_UNCHANGED == 'yes'
+        # figure how much we should muck around with the email body
+        keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT',
+            'no') == 'yes'
+        keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED',
+            'no') == 'yes'
+
+        # parse the body of the message, stripping out bits as appropriate
         summary, content = parseContent(content, keep_citations, 
             keep_body)
 
@@ -619,150 +696,94 @@ not find a text/plain part to use.
             files.append(self.db.file.create(type=mime_type, name=name,
                 content=data))
 
+        # 
+        # create the message if there's a message body (content)
         #
-        # now handle the db stuff
-        #
-        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
-
-            # update the nosy list
-            current = {}
-            for nid in cl.get(nodeid, 'nosy'):
-                current[nid] = 1
-            self.updateNosy(props, author, recipients, current)
-
-            # create the message
+        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:
+
+            # attach the message to the node
+            if nodeid:
+                # add the message to the node's list
                 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.
+                messages.append(message_id)
+                props['messages'] = messages
+            else:
+                # pre-load the messages list
+                props['messages'] = [message_id]
 
-Subject was: "%s"
-'''%(nodeid, subject)
-            messages.append(message_id)
-            props['messages'] = messages
+                # set the title to the subject
+                if properties.has_key('title') and not props.has_key('title'):
+                    props['title'] = title
 
-            # now apply the changes
-            try:
+        #
+        # perform the node change / create
+        #
+        try:
+            if nodeid:
                 cl.set(nodeid, **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'):
-                try:
-                    # determine the id of 'unread'
-                    unread_id = self.db.status.lookup('unread')
-                except KeyError:
-                    pass
-                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
-            self.updateNosy(props, author, recipients)
-
-            # and attempt to create the new node
-            try:
+            else:
                 nodeid = cl.create(**props)
-            except (TypeError, IndexError, ValueError), message:
-                raise MailUsageError, '''
+        except (TypeError, IndexError, ValueError), message:
+            raise MailUsageError, '''
 There was a problem with the message you sent:
    %s
 '''%message
 
-            # commit the new node(s) to the DB
-            self.db.commit()
+        # commit the changes to the DB
+        self.db.commit()
 
         return nodeid
 
-    def updateNosy(self, props, author, recipients, current=None):
-        '''Determine what the nosy list should be given:
-
-            props:      properties specified on the subject line of the message
-            author:     the sender of the message
-            recipients: the recipients (to, cc) of the message
-            current:    if the issue already exists, this is the current nosy
-                        list, as a dictionary.
-        '''
-        if current is None:
-            current = {}
-            ok = ('new', 'yes')
-        else:
-            ok = ('yes',)
-
-        # add nosy in arguments to issue's nosy list
-        nosy = props.get('nosy', [])
-        for value in nosy:
-            if not self.db.hasnode('user', value):
+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
-            if not current.has_key(value):
-                current[value] = 1
-
-        # add the author to the nosy list
-        if getattr(self.instance, 'ADD_AUTHOR_TO_NOSY', 'new') in ok:
-            if not current.has_key(author):
-                current[author] = 1
-
-        # add on the recipients of the message
-        if getattr(self.instance, 'ADD_RECIPIENTS_TO_NOSY', 'new') in ok:
-            for recipient in recipients:
-                if not current.has_key(recipient):
-                    current[recipient] = 1
-
-        # add assignedto to the nosy list
-        if props.has_key('assignedto'):
-            assignedto = props['assignedto']
-            if not current.has_key(assignedto):
-                current[assignedto] = 1
-
-        props['nosy'] = current.keys()
+            # 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):
+    ''' address is from the rfc822 module, and therefore is (name, addr)
+
+        user is created if they don't exist in the db already
+    '''
+    (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:
+        return db.user.create(username=address, address=address,
+            realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES)
+    else:
+        return 0
 
 def parseContent(content, keep_citations, keep_body,
         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
@@ -823,7 +844,6 @@ def parseContent(content, keep_citations, keep_body,
             # ditch the stupid Outlook quoting of the entire original message
             break
 
-
         # and add the section to the output
         l.append(section)
     # we only set content for those who want to delete cruft from the
@@ -834,6 +854,62 @@ def parseContent(content, keep_citations, keep_body,
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.80  2002/08/01 00:56:22  richard
+# Added the web access and email access permissions, so people can restrict
+# access to users who register through the email interface (for example).
+# Also added "security" command to the roundup-admin interface to display the
+# Role/Permission config for an instance.
+#
+# Revision 1.79  2002/07/26 08:26:59  richard
+# Very close now. The cgi and mailgw now use the new security API. The two
+# templates have been migrated to that setup. Lots of unit tests. Still some
+# issue in the web form for editing Roles assigned to users.
+#
+# Revision 1.78  2002/07/25 07:14:06  richard
+# Bugger it. Here's the current shape of the new security implementation.
+# Still to do:
+#  . call the security funcs from cgi and mailgw
+#  . change shipped templates to include correct initialisation and remove
+#    the old config vars
+# ... that seems like a lot. The bulk of the work has been done though. Honest :)
+#
+# Revision 1.77  2002/07/18 11:17:31  gmcm
+# Add Number and Boolean types to hyperdb.
+# Add conversion cases to web, mail & admin interfaces.
+# Add storage/serialization cases to back_anydbm & back_metakit.
+#
+# Revision 1.76  2002/07/10 06:39:37  richard
+#  . made mailgw handle set and modify operations on multilinks (bug #579094)
+#
+# Revision 1.75  2002/07/09 01:21:24  richard
+# Added ability for unit tests to turn off exception handling in mailgw so
+# that exceptions are reported earlier (and hence make sense).
+#
+# Revision 1.74  2002/05/29 01:16:17  richard
+# Sorry about this huge checkin! It's fixing a lot of related stuff in one go
+# though.
+#
+# . #541941 ] changing multilink properties by mail
+# . #526730 ] search for messages capability
+# . #505180 ] split MailGW.handle_Message
+#   - also changed cgi client since it was duplicating the functionality
+# . build htmlbase if tests are run using CVS checkout (removed note from
+#   installation.txt)
+# . don't create an empty message on email issue creation if the email is empty
+#
+# Revision 1.73  2002/05/22 04:12:05  richard
+#  . applied patch #558876 ] cgi client customization
+#    ... with significant additions and modifications ;)
+#    - extended handling of ML assignedto to all places it's handled
+#    - added more NotFound info
+#
+# Revision 1.72  2002/05/22 01:24:51  richard
+# Added note to MIGRATION about new config vars. Also made us more resilient
+# for upgraders. Reinstated list header style (oops)
+#
+# Revision 1.71  2002/05/08 02:40:55  richard
+# grr
+#
 # Revision 1.70  2002/05/06 23:40:07  richard
 # hrm
 #