X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fmailgw.py;h=66102ebee56a4ffbaed38b8f50e99b20a0857d19;hb=62c10fff68eaded0e04c395e1b58faf92f17cb44;hp=07a041d63ced5e28e723bc08bb1c795536da26d8;hpb=9c438c70783bcdc1f659796bc95404848cd61786;p=roundup.git diff --git a/roundup/mailgw.py b/roundup/mailgw.py index 07a041d..66102eb 100644 --- a/roundup/mailgw.py +++ b/roundup/mailgw.py @@ -16,7 +16,7 @@ # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -__doc__ = ''' +''' An e-mail gateway for Roundup. Incoming messages are examined for multiple parts: @@ -73,13 +73,14 @@ 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.86 2002-09-10 12:44:42 richard Exp $ +$Id: mailgw.py,v 1.116 2003-04-17 06:51:44 richard 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 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '') @@ -92,6 +93,10 @@ class MailUsageError(ValueError): class MailUsageHelp(Exception): pass +class MailLoop(Exception): + ''' We've seen this message before... ''' + pass + class Unauthorized(Exception): """ Access denied """ @@ -107,6 +112,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... @@ -130,19 +155,113 @@ class Message(mimetools.Message): s.seek(0) 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[^[]+)?(\[(?P<args>.+?)\])?', re.I) + def getheader(self, name, default=None): + hdr = mimetools.Message.getheader(self, name, default) + 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: - def __init__(self, instance, db): + def __init__(self, instance, db, arguments={}): self.instance = instance self.db = db + self.arguments = arguments # 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 + 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. ''' @@ -189,8 +308,12 @@ class MailGW: m = [''] m.append(str(value)) m = self.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 + # 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') @@ -204,6 +327,7 @@ class MailGW: m = self.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... sendto = [self.instance.config.ADMIN_EMAIL] m = ['Subject: badly formed message from mail gateway'] m.append('') @@ -216,7 +340,7 @@ class MailGW: # now send the message if SENDMAILDEBUG: - open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%( + open(SENDMAILDEBUG, 'a').write('From: %s\nTo: %s\n%s\n'%( self.instance.config.ADMIN_EMAIL, ', '.join(sendto), m.getvalue())) else: @@ -238,45 +362,34 @@ class MailGW: ''' 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') + body = part.startbody('text/plain; charset=utf-8') 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 + # 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: - 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) + body.write(header) + body.write('\n') try: message.rewindbody() - except IOError: - body.write("*** couldn't include message body: read from pipe ***") + except IOError, message: + body.write("*** couldn't include message body: %s ***"%message) 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 @@ -297,17 +410,50 @@ class MailGW: else: # take it as text data = part.fp.read() - return data + + # 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: + # 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 subject.strip().lower() == 'help': raise MailUsageHelp m = subject_re.match(subject) @@ -362,6 +508,11 @@ 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 nodeid is None and not title: raise MailUsageError, ''' @@ -391,6 +542,49 @@ 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. + 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, ''' +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 # @@ -405,17 +599,24 @@ Subject was: "%s" author = uidFromAddress(self.db, message.getaddrlist('from')[0], create=create) - # no author? means we're not author + # 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] - - # 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.' + 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): @@ -427,6 +628,7 @@ Unknown address: %s # 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 @@ -442,14 +644,14 @@ Unknown address: %s # look up the recipient - create if necessary (and we're # allowed to) - recipient = uidFromAddress(self.db, recipient, create) + recipient = uidFromAddress(self.db, recipient, create, **user_props) # if all's well, add the recipient to the list if recipient: recipients.append(recipient) # - # extract the args + # XXX extract the args NOT USED WHY -- rouilj # subject_args = m.group('args') @@ -461,113 +663,7 @@ Unknown address: %s props = {} args = m.group('args') if args: - errors = [] - for prop in string.split(args, ';'): - # 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...]') - break - - # 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()) - 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: - 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: - 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) - + errors, props = setPropArrayFromString(self, cl, args, nodeid) # handle any errors parsing the argument list if errors: errors = '\n- '.join(errors) @@ -641,9 +737,30 @@ Subject was: "%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 data = self.get_part_data_decoded(part) attachments.append((name, part.gettype(), data)) @@ -681,14 +798,15 @@ not find a text/plain part to use. content = self.get_part_data_decoded(message) # figure how much we should muck around with the email body - keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT', + keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT', 'no') == 'yes' - keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED', + 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 @@ -698,7 +816,7 @@ 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)) + content=data, **file_props)) # # create the message if there's a message body (content) @@ -707,7 +825,7 @@ not find a text/plain part to use. message_id = self.db.msg.create(author=author, recipients=recipients, date=date.Date('.'), summary=summary, content=content, files=files, messageid=messageid, - inreplyto=inreplyto) + inreplyto=inreplyto, **msg_props) # attach the message to the node if nodeid: @@ -727,6 +845,12 @@ not find a text/plain part to use. # 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) else: @@ -742,6 +866,119 @@ There was a problem with the message you sent: 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: + 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: + 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 @@ -759,24 +996,27 @@ def extractUserFromList(userClass, users): return users[0] return None -def uidFromAddress(db, address, create=1): + +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 + 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}, - [], []) + 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) @@ -784,20 +1024,44 @@ def uidFromAddress(db, address, create=1): # 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) + # 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 + 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-----$')): + 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 @@ -822,7 +1086,7 @@ def parseContent(content, keep_citations, keep_body, # 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: # we keep quoted bits if specified in the config @@ -830,30 +1094,41 @@ def parseContent(content, keep_citations, keep_body, 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] + summary = section elif signature.match(lines[0]) and 2 <= len(lines) <= 10: # lose any signature break - elif original_message.match(lines[0]): + 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) - # we only set content for those who want to delete cruft from the - # message body, otherwise the body is left untouched. + + # 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