diff --git a/roundup/mailgw.py b/roundup/mailgw.py
index 726cee0929918a0e442b22c3b97ead985b2436b4..f45eedc9d51af4e3e6e0a88259c054676215dcdb 100644 (file)
--- a/roundup/mailgw.py
+++ b/roundup/mailgw.py
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
#
-__doc__ = '''
-An e-mail gateway for Roundup.
+"""An e-mail gateway for Roundup.
Incoming messages are examined for multiple parts:
. In a multipart/mixed message or part, each subpart is extracted and
an exception, the original message is bounced back to the sender with the
explanatory message given in the exception.
-$Id: mailgw.py,v 1.92 2002-09-26 03:03:18 richard Exp $
-'''
+$Id: mailgw.py,v 1.143 2004-02-11 23:55:08 richard Exp $
+"""
+__docformat__ = 'restructuredtext'
import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
import time, random, sys
-import traceback, MimeWriter
-import hyperdb, date, password
+import traceback, MimeWriter, rfc822
+
+from roundup import hyperdb, date, password, rfc2822
+from roundup.mailer import Mailer
SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
pass
class MailUsageHelp(Exception):
+ """ We need to send the help message to the user. """
pass
class Unauthorized(Exception):
""" Access denied """
+ pass
+
+class IgnoreMessage(Exception):
+ """ A general class of message that we should ignore. """
+ pass
+class IgnoreBulk(IgnoreMessage):
+ """ This is email from a mailing list or from a vacation program. """
+ pass
+class IgnoreLoop(IgnoreMessage):
+ """ We've seen this message before... """
+ pass
def initialiseSecurity(security):
''' Create some Permissions and Roles on the security object
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...
'''
- def getPart(self):
+ def getpart(self):
''' Get a single part of a multipart message and return it as a new
Message instance.
'''
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<quote>")?(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
- r'\s*(?P<title>[^[]+)?"?(\[(?P<args>.+?)\])?', re.I)
+ def getparts(self):
+ """Get all parts of this multipart message."""
+ # skip over the intro to the first boundary
+ self.getpart()
+
+ # accumulate the other parts
+ parts = []
+ while 1:
+ part = self.getpart()
+ if part is None:
+ break
+ parts.append(part)
+ return parts
+
+ def getheader(self, name, default=None):
+ hdr = mimetools.Message.getheader(self, name, default)
+ if hdr:
+ hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
+ return rfc2822.decode_header(hdr)
+
+ def getname(self):
+ """Find an appropriate name for this message."""
+ if self.gettype() == 'message/rfc822':
+ # handle message/rfc822 specially - the name should be
+ # the subject of the actual e-mail embedded here
+ self.fp.seek(0)
+ name = Message(self.fp).getheader('subject')
+ else:
+ # try name on Content-Type
+ name = self.getparam('name')
+ if not name:
+ disp = self.getheader('content-disposition', None)
+ if disp:
+ name = getparam(disp, 'filename')
+
+ if name:
+ return name.strip()
+
+ def getbody(self):
+ """Get the decoded message body."""
+ self.rewindbody()
+ encoding = self.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(self.fp.read())
+ elif encoding == 'quoted-printable':
+ # the quopri module wants to work with files
+ decoded = cStringIO.StringIO()
+ quopri.decode(self.fp, decoded)
+ data = decoded.getvalue()
+ elif encoding == 'uuencoded':
+ data = binascii.a2b_uu(self.fp.read())
+ else:
+ # take it as text
+ data = self.fp.read()
+
+ # Encode message to unicode
+ charset = rfc2822.unaliasCharset(self.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
+
+ # 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.
+
+ def extract_content(self, parent_type=None):
+ """Extract the body and the attachments recursively."""
+ content_type = self.gettype()
+ content = None
+ attachments = []
+
+ if content_type == 'text/plain':
+ content = self.getbody()
+ elif content_type[:10] == 'multipart/':
+ for part in self.getparts():
+ new_content, new_attach = part.extract_content(content_type)
+
+ # If we haven't found a text/plain part yet, take this one,
+ # otherwise make it an attachment.
+ if not content:
+ content = new_content
+ elif new_content:
+ attachments.append(part.as_attachment())
+
+ attachments.extend(new_attach)
+ elif (parent_type == 'multipart/signed' and
+ content_type == 'application/pgp-signature'):
+ # ignore it so it won't be saved as an attachment
+ pass
+ else:
+ attachments.append(self.as_attachment())
+ return content, attachments
+
+ def as_attachment(self):
+ """Return this message as an attachment."""
+ return (self.getname(), self.gettype(), self.getbody())
class MailGW:
- def __init__(self, instance, db):
+
+ # Matches subjects like:
+ # Re: "[issue1234] title of issue [status=resolved]"
+ subject_re = re.compile(r'''
+ (?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*\s* # Re:
+ (?P<quote>")? # Leading "
+ (\[(?P<classname>[^\d\s]+) # [issue..
+ (?P<nodeid>\d+)? # ..1234]
+ \])?\s*
+ (?P<title>[^[]+)? # issue title
+ "? # Trailing "
+ (\[(?P<args>.+?)\])? # [prop=value]
+ ''', re.IGNORECASE|re.VERBOSE)
+
+ def __init__(self, instance, db, arguments={}):
self.instance = instance
self.db = db
+ self.arguments = arguments
+ self.mailer = Mailer(instance.config)
# 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.
- '''
- self.main(sys.stdin)
+ """ 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
+ """ 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
+ import fcntl
+ # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
+ if hasattr(fcntl, 'LOCK_EX'):
+ FCNTL = fcntl
+ else:
+ import FCNTL
f = open(filename, 'r+')
fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
return 0
- def do_pop(self, server, user='', password=''):
+ 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: '))
+ user = raw_input('User: ')
if not password:
password = getpass.getpass()
except (KeyboardInterrupt, EOFError):
except socket.error, message:
print "POP server error:", message
return 1
- server.user(user)
- server.pass_(password)
+ 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
return self.handle_Message(Message(fp))
def handle_Message(self, message):
- '''Handle an RFC822 Message
+ """Handle an RFC822 Message
Handle the Message object by calling handle_message() and then cope
with any errors raised by handle_message.
This method's job is to make that call and handle any
errors in a sane manner. It should be replaced if you wish to
handle errors in a different manner.
- '''
+ """
# in some rare cases, a particularly stuffed-up e-mail will make
# 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:
- # bounce the message back to the sender with the usage message
- fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
- sendto = [sendto[0][1]]
- m = ['']
- m.append('\n\nMail Gateway Help\n=================')
- m.append(fulldoc)
- m = self.bounce_message(message, sendto, m,
- subject="Mail Gateway Help")
- except MailUsageError, value:
- # bounce the message back to the sender with the usage message
- fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
- sendto = [sendto[0][1]]
- m = ['']
- m.append(str(value))
- m.append('\n\nMail Gateway Help\n=================')
- m.append(fulldoc)
- m = self.bounce_message(message, sendto, m)
- except Unauthorized, value:
- # just inform the user that he is not authorized
- sendto = [sendto[0][1]]
- m = ['']
- m.append(str(value))
- m = self.bounce_message(message, sendto, m)
- except:
- # bounce the message back to the sender with the error message
- sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
- m = ['']
- m.append('An unexpected error occurred during the processing')
- m.append('of your message. The tracker administrator is being')
- m.append('notified.\n')
- m.append('---- traceback of failure ----')
- s = cStringIO.StringIO()
- import traceback
- traceback.print_exc(None, s)
- m.append(s.getvalue())
- m = self.bounce_message(message, sendto, m)
- else:
+ sendto = message.getaddrlist('resent-from')
+ if not sendto:
+ sendto = message.getaddrlist('from')
+ if not sendto:
# 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('')
m.append('line, indicating that it is corrupt. Please check your')
m.append('mail gateway source. Failed message is attached.')
m.append('')
- m = self.bounce_message(message, sendto, m,
+ self.mailer.bounce_message(message, sendto, m,
subject='Badly formed message from mail gateway')
+ return
- # now send the message
- if SENDMAILDEBUG:
- open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
- self.instance.config.ADMIN_EMAIL, ', '.join(sendto),
- m.getvalue()))
- else:
- try:
- smtp = smtplib.SMTP(self.instance.config.MAILHOST)
- smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto,
- m.getvalue())
- except socket.error, value:
- raise MailGWError, "Couldn't send error email: "\
- "mailhost %s"%value
- except smtplib.SMTPException, value:
- raise MailGWError, "Couldn't send error email: %s"%value
-
- def bounce_message(self, message, sendto, error,
- subject='Failed issue tracker submission'):
- ''' create a message that explains the reason for the failed
- issue submission to the author and attach the original
- message.
- '''
- msg = cStringIO.StringIO()
- writer = MimeWriter.MimeWriter(msg)
- 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('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
- 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)
+ # try normal message-handling
+ if not self.trapExceptions:
+ return self.handle_message(message)
try:
- message.rewindbody()
- except IOError:
- body.write("*** couldn't include message body: read from pipe ***")
- 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
-
- 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
+ return self.handle_message(message)
+ except MailUsageHelp:
+ # bounce the message back to the sender with the usage message
+ fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
+ sendto = [sendto[0][1]]
+ m = ['']
+ m.append('\n\nMail Gateway Help\n=================')
+ m.append(fulldoc)
+ self.mailer.bounce_message(message, sendto, m,
+ subject="Mail Gateway Help")
+ except MailUsageError, value:
+ # bounce the message back to the sender with the usage message
+ fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
+ sendto = [sendto[0][1]]
+ m = ['']
+ m.append(str(value))
+ m.append('\n\nMail Gateway Help\n=================')
+ m.append(fulldoc)
+ self.mailer.bounce_message(message, sendto, m)
+ except Unauthorized, value:
+ # just inform the user that he is not authorized
+ sendto = [sendto[0][1]]
+ m = ['']
+ m.append(str(value))
+ self.mailer.bounce_message(message, sendto, m)
+ except IgnoreMessage:
+ # XXX we should use a log file here...
+ # do not take any action
+ # this exception is thrown when email should be ignored
+ 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.append('of your message. The tracker administrator is being')
+ m.append('notified.\n')
+ m.append('---- traceback of failure ----')
+ s = cStringIO.StringIO()
+ import traceback
+ traceback.print_exc(None, s)
+ m.append(s.getvalue())
+ self.mailer.bounce_message(message, sendto, m)
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 IgnoreLoop
+
+ # detect Precedence: Bulk
+ if (message.getheader('precedence', '') == 'bulk'):
+ raise IgnoreBulk
+
+ # 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
+
+ # determine the sender's address
+ from_list = message.getaddrlist('resent-from')
+ if not from_list:
+ from_list = message.getaddrlist('from')
+
# handle the subject line
subject = message.getheader('subject', '')
- if subject.strip() == 'help':
+ if not subject:
+ raise MailUsageError, '''
+Emails to Roundup trackers must include a Subject: line!
+'''
+
+ if subject.strip().lower() == 'help':
raise MailUsageHelp
- m = subject_re.match(subject)
+ m = self.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.config, 'MAIL_DEFAULT_CLASS') and \
- self.instance.config.MAIL_DEFAULT_CLASS:
+ # no classname, check if this a registration confirmation email
+ # or fallback on the default class
+ otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
+ otk = otk_re.search(m.group('title'))
+ if otk:
+ self.db.confirm_registration(otk.group('otk'))
+ subject = 'Your registration to %s is complete' % \
+ self.instance.config.TRACKER_NAME
+ sendto = [from_list[0][1]]
+ self.mailer.standard_message(sendto, subject, '')
+ return
+ elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
+ self.instance.config.MAIL_DEFAULT_CLASS:
classname = self.instance.config.MAIL_DEFAULT_CLASS
else:
# fail
m = None
if not m:
- raise MailUsageError, '''
+ raise MailUsageError, """
The message you sent to roundup did not contain a properly formed subject
line. The subject must contain a class name or designator to indicate the
-"topic" of the message. For example:
+'topic' of the message. For example:
Subject: [issue] This is a new issue
- - this will create a new issue in the tracker with the title "This is
- a new issue".
+ - this will create a new issue in the tracker with the title 'This is
+ a new issue'.
Subject: [issue1234] This is a followup to issue 1234
- this will append the message's contents to the existing issue 1234
in the tracker.
-Subject was: "%s"
-'''%subject
+Subject was: '%s'
+"""%subject
# get the class
try:
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
#
# 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)
+ author = uidFromAddress(self.db, from_list[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, '''
-You are not a registered user.
-
-Unknown address: %s
-'''%message.getaddrlist('from')[0][1]
+ author = anonid
# 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.'
+ 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
+'''%from_list[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):
# 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
- #
- subject_args = m.group('args')
-
#
# handle the subject argument list
#
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)
+ errors = '\n- '.join(map(str, errors))
raise MailUsageError, '''
There were problems handling your subject line argument list:
- %s
Subject was: "%s"
'''%(errors, subject)
+
+ # set the issue title to the subject
+ if properties.has_key('title') and not issue_props.has_key('title'):
+ issue_props['title'] = title.strip()
+
#
# handle message-id and in-reply-to
#
messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
classname, nodeid, self.instance.config.MAIL_DOMAIN)
- #
# now handle the body - find the message
- #
- 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()
- content = None
- while 1:
- # get the next part
- part = message.getPart()
- if part is None:
- break
- # parse it
- subtype = part.gettype()
- if subtype == 'text/plain' and not content:
- # 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
- i = part.fp.tell()
- mailmess = Message(part.fp)
- 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
- data = self.get_part_data_decoded(part)
- attachments.append((name, part.gettype(), data))
- if content is None:
- raise MailUsageError, '''
-Roundup requires the submission to be plain text. The message parser could
-not find a text/plain part to use.
-'''
-
- elif content_type[:10] == 'multipart/':
- # skip over the intro to the first boundary
- message.getPart()
- content = None
- while 1:
- # get the next part
- part = message.getPart()
- if part is None:
- break
- # parse it
- if part.gettype() == 'text/plain' and not content:
- 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
-not find a text/plain part to use.
-'''
-
- elif content_type != 'text/plain':
+ content, attachments = message.extract_content()
+ if content is None:
raise MailUsageError, '''
Roundup requires the submission to be plain text. The message parser could
not find a text/plain part to use.
'''
-
- else:
- 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
#
- files = []
- for (name, mime_type, data) in attachments:
- if not name:
- name = "unnamed"
- files.append(self.db.file.create(type=mime_type, name=name,
- content=data))
+ if properties.has_key('files'):
+ files = []
+ for (name, mime_type, data) in attachments:
+ if not name:
+ name = "unnamed"
+ files.append(self.db.file.create(type=mime_type, name=name,
+ content=data, **file_props))
+ # attach the files to the issue
+ if nodeid:
+ # extend the existing files list
+ fileprop = cl.get(nodeid, 'files')
+ fileprop.extend(files)
+ props['files'] = fileprop
+ else:
+ # pre-load the files list
+ props['files'] = files
#
# create the message if there's a message body (content)
#
- if content:
+ if (content and properties.has_key('messages')):
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:
# pre-load the messages list
props['messages'] = [message_id]
- # set the title to the subject
- if properties.has_key('title') and not props.has_key('title'):
- props['title'] = title
-
#
# 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:
return nodeid
+
+def setPropArrayFromString(self, cl, propString, nodeid=None):
+ ''' takes string of form prop=value,value;prop2=value
+ and returns (error, prop[..])
+ '''
+ 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)
+ # convert the value to a hyperdb-usable value
+ propname = propname.strip()
+ try:
+ props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+ propname, value)
+ except hyperdb.HyperdbValueError, message:
+ errors.append(message)
+ 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
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)
# 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-----$')):
+ signature=re.compile(r'^[>|\s]*-- ?$'),
+ 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
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)
+ # 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: