X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fmailgw.py;h=53dc401d20dea4d9c4ba0613aa34621586ec9f67;hb=111ccd0816bc9eb34a62b1d9f6dd98c50458bc46;hp=b624b1bc6f560e3cc6db156344d38166c9bd7855;hpb=1cbbb3c14250a5802ae6422a27da2e158618a411;p=roundup.git diff --git a/roundup/mailgw.py b/roundup/mailgw.py index b624b1b..53dc401 100644 --- a/roundup/mailgw.py +++ b/roundup/mailgw.py @@ -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.62 2002-02-05 14:15:29 grubert Exp $ +$Id: mailgw.py,v 1.74 2002-05-29 01:16:17 richard Exp $ ''' @@ -120,7 +120,7 @@ class Message(mimetools.Message): return Message(s) subject_re = re.compile(r'(?P\s*\W?\s*(fwd|re|aw)\s*\W?\s*)*' - r'\s*(\[(?P[^\d\s]+)(?P\d+)?\])' + r'\s*(\[(?P[^\d\s]+)(?P\d+)?\])?' r'\s*(?P[^[]+)?(\[(?P<args>.+?)\])?', re.I) class MailGW: @@ -131,7 +131,7 @@ class MailGW: 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 @@ -261,6 +261,25 @@ class MailGW: writer.lastpart() return msg + 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: + # take it as text + data = part.fp.read() + return data + def handle_message(self, message): ''' message - a Message instance @@ -273,6 +292,20 @@ class MailGW: raise MailUsageHelp m = 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, 'MAIL_DEFAULT_CLASS') and \ + self.instance.MAIL_DEFAULT_CLASS: + classname = self.instance.MAIL_DEFAULT_CLASS + else: + # fail + m = None + if not m: raise MailUsageError, ''' The message you sent to roundup did not contain a properly formed subject @@ -288,8 +321,7 @@ line. The subject must contain a class name or designator to indicate the Subject was: "%s" '''%subject - # get the classname - classname = m.group('classname') + # get the class try: cl = self.db.getclass(classname) except KeyError: @@ -312,7 +344,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 @@ -321,115 +353,149 @@ 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 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:] + + # 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: + if item not in curvalue: + curvalue.append(item) + + # that's it, set the new Multilink property value + props[propname] = curvalue + + # 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 is denied - if self.instance.ANONYMOUS_REGISTER == 'deny': + # Don't create users if ANONYMOUS_REGISTER_MAIL is denied + # ... fall back on ANONYMOUS_REGISTER if the other doesn't exist + create = 1 + if hasattr(self.instance, 'ANONYMOUS_REGISTER_MAIL'): + if self.instance.ANONYMOUS_REGISTER_MAIL == 'deny': + create = 0 + elif self.instance.ANONYMOUS_REGISTER == 'deny': create = 0 - else: - create = 1 + author = self.db.uidFromAddress(message.getaddrlist('from')[0], create=create) if not author: @@ -457,7 +523,14 @@ Unknown address: %s 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 = self.db.uidFromAddress(recipient, create) + + # if all's well, add the recipient to the list + if recipient: + recipients.append(recipient) # # handle message-id and in-reply-to @@ -474,6 +547,32 @@ Unknown address: %s # 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 +585,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 +595,11 @@ Unknown address: %s name = mailmess.getheader('subject') part.fp.seek(i) attachments.append((name, 'message/rfc822', part.fp.read())) - else: # try name on Content-Type name = part.getparam('name') # 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 +618,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 +632,17 @@ 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, '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) # # handle the attachments @@ -597,144 +654,53 @@ 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 - - # 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) - + 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 - 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 - 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 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_message=re.compile(r'^[>|\s]*-----Original Message-----$')): ''' 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 @@ -766,9 +732,9 @@ def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'), if 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: @@ -782,15 +748,73 @@ def parseContent(content, blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'), # if we don't have our summary yet use the first line of this # section summary = lines[0] - elif signature.match(lines[0]): + elif signature.match(lines[0]) and 2 <= len(lines) <= 10: + # lose any signature + break + elif original_message.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) + # we only set content for those who want to delete cruft from the + # message body, otherwise the body is left untouched. + if not keep_body: + content = '\n\n'.join(l) + return summary, content # # $Log: not supported by cvs2svn $ +# 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 +# +# Revision 1.69 2002/05/06 23:37:21 richard +# Tweaking the signature deletion from mail messages. +# Added nuking of the "-----Original Message-----" crap from Outlook. +# +# Revision 1.68 2002/05/02 07:56:34 richard +# . added option to automatically add the authors and recipients of messages +# to the nosy lists with the options ADD_AUTHOR_TO_NOSY (default 'new') and +# ADD_RECIPIENTS_TO_NOSY (default 'new'). These settings emulate the current +# behaviour. Setting them to 'yes' will add the author/recipients to the nosy +# on messages that create issues and followup messages. +# . added missing documentation for a few of the config option values +# +# Revision 1.67 2002/04/23 15:46:49 rochecompaan +# . stripping of the email message body can now be controlled through +# the config variables EMAIL_KEEP_QUOTED_TEST and +# EMAIL_LEAVE_BODY_UNCHANGED. +# +# Revision 1.66 2002/03/14 23:59:24 richard +# . #517734 ] web header customisation is obscure +# +# Revision 1.65 2002/02/15 00:13:38 richard +# . #503204 ] mailgw needs a default class +# - partially done - the setting of additional properties can wait for a +# better configuration system. +# +# Revision 1.64 2002/02/14 23:46:02 richard +# . #516883 ] mail interface + ANONYMOUS_REGISTER +# +# Revision 1.63 2002/02/12 08:08:55 grubert +# . Clean up mail handling, multipart handling. +# +# Revision 1.62 2002/02/05 14:15:29 grubert +# . respect encodings in non multipart messages. +# # Revision 1.61 2002/02/04 09:40:21 grubert # . add test for multipart messages with first part being encoded. #