diff --git a/roundup/mailgw.py b/roundup/mailgw.py
index 80462780a91f6d88d6b231470d51bff2c5a08f05..9de891be5ccd136bbbc6de31f5b9800ab533c9bb 100644 (file)
--- a/roundup/mailgw.py
+++ b/roundup/mailgw.py
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
#
-__doc__ = '''
+'''
An e-mail gateway for Roundup.
Incoming messages are examined for multiple parts:
an exception, the original message is bounced back to the sender with the
explanatory message given in the exception.
-$Id: mailgw.py,v 1.83 2002-09-10 00:18:20 richard Exp $
+$Id: mailgw.py,v 1.102 2002-12-11 01:52:20 richard Exp $
'''
-
import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
-import time, random
+import time, random, sys
import traceback, MimeWriter
import hyperdb, date, password
class MailUsageHelp(Exception):
pass
+class MailLoop(Exception):
+ ''' We've seen this message before... '''
+ pass
+
class Unauthorized(Exception):
""" Access denied """
s.seek(0)
return Message(s)
-subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re|aw)\s*\W?\s*)*'
- r'\s*(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
- r'\s*(?P<title>[^[]+)?(\[(?P<args>.+?)\])?', re.I)
+subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(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):
# (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_pop(self, server, user='', password=''):
+ '''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
+ 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.
'''
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')
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('')
'''
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.INSTANCE_NAME,
- self.instance.config.ISSUE_TRACKER_EMAIL))
+ 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.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
Parse the message as per the module docstring.
'''
+ # detect loops
+ if message.getheader('x-roundup-loop', ''):
+ raise MailLoop
+
# handle the subject line
subject = message.getheader('subject', '')
classname = m.group('classname')
if classname is None:
# no classname, fallback on the default
- if hasattr(self.instance, 'MAIL_DEFAULT_CLASS') and \
+ if hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
self.instance.config.MAIL_DEFAULT_CLASS:
classname = self.instance.config.MAIL_DEFAULT_CLASS
else:
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, '''
Subject was: "%s"
'''%(nodeid, subject)
+ #
+ # handle the users
+ #
+ # Don't create users if anonymous isn't allowed to register
+ create = 1
+ anonid = self.db.user.lookup('anonymous')
+ if not self.db.security.hasPermission('Email Registration', anonid):
+ create = 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)
+
+ # if we're not recognised, and we don't get added as a user, then we
+ # must be anonymous
+ if not author:
+ 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]
+ 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):
+ raise Unauthorized, 'You are not permitted to edit %s.'%classname
+
+ # the author may have been created - make sure the change is
+ # committed before we reopen the database
+ self.db.commit()
+
+ # 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
+ cl = self.db.getclass(classname)
+
+ # now update the recipients list
+ recipients = []
+ tracker_email = self.instance.config.TRACKER_EMAIL.lower()
+ for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
+ r = recipient[1].strip().lower()
+ if r == tracker_email or not r:
+ continue
+
+ # look up the recipient - create if necessary (and we're
+ # allowed to)
+ recipient = uidFromAddress(self.db, recipient, create)
+
+ # if all's well, add the recipient to the list
+ if recipient:
+ recipients.append(recipient)
+
#
# extract the args
#
Subject was: "%s"
'''%(errors, subject)
- #
- # handle the users
- #
-
- # Don't create users if anonymous isn't allowed to register
- create = 1
- anonid = self.db.user.lookup('anonymous')
- if not self.db.security.hasPermission('Email Registration', anonid):
- create = 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, '''
-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()
-
- # reopen the database as the author
- username = self.db.user.get(author, 'username')
- self.db = self.instance.open(username)
-
- # re-get the class with the new database connection
- cl = self.db.getclass(classname)
-
- # now update the recipients list
- recipients = []
- tracker_email = self.instance.config.ISSUE_TRACKER_EMAIL.lower()
- for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
- r = recipient[1].strip().lower()
- if r == tracker_email or not r:
- continue
-
- # look up the recipient - create if necessary (and we're
- # allowed to)
- recipient = uidFromAddress(self.db, recipient, create)
-
- # if all's well, add the recipient to the list
- if recipient:
- recipients.append(recipient)
-
#
# handle message-id and in-reply-to
#
attachments.append((name, 'message/rfc822', part.fp.read()))
else:
# try name on Content-Type
- name = part.getparam('name')
+ name = part.getparam('name').strip()
+ if not name:
+ disp = part.getheader('content-disposition', None)
+ if disp:
+ name = disp.getparam('filename').strip()
# this is just an attachment
data = self.get_part_data_decoded(part)
attachments.append((name, part.gettype(), data))
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
# 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
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
- 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
# 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
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
# 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