diff --git a/roundup/mailgw.py b/roundup/mailgw.py
index 23e294315d474aeb23fd3760766311606f6f813c..c76706c4f586414e459e1aa23fcb0a0720e77b0b 100644 (file)
--- a/roundup/mailgw.py
+++ b/roundup/mailgw.py
an exception, the original message is bounced back to the sender with the
explanatory message given in the exception.
-$Id: mailgw.py,v 1.132 2003-09-08 21:27:16 jlgijsbers Exp $
+$Id: mailgw.py,v 1.141 2004-01-17 13:49:06 jlgijsbers Exp $
"""
import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
pass
class MailUsageHelp(Exception):
- pass
-
-class MailLoop(Exception):
- """ We've seen this message before... """
+ """ 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
''' 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)
+ 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:
# Matches subjects like:
"""
# 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)
- 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 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.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)
- 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.append('')
self.mailer.bounce_message(message, sendto, m,
subject='Badly formed message from mail gateway')
+ return
- 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()
-
- # 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
+ # try normal message-handling
+ 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)
+ 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
'''
# detect loops
if message.getheader('x-roundup-loop', ''):
- raise MailLoop
+ 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.]"
# 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', '')
self.db.confirm_registration(otk.group('otk'))
subject = 'Your registration to %s is complete' % \
self.instance.config.TRACKER_NAME
- sendto = message.getaddrlist('from')[0][1]
+ sendto = [from_list[0][1]]
self.mailer.standard_message(sendto, subject, '')
return
elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
# 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)
# if we're not recognised, and we don't get added as a user, then we
# must be anonymous
You are not a registered user.
Unknown address: %s
-'''%message.getaddrlist('from')[0][1]
+'''%from_list[0][1]
else:
# we're registered and we're _still_ not allowed access
raise Unauthorized, 'You are not permitted to access '\
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
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()))
- 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))
- 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.config, 'EMAIL_KEEP_QUOTED_TEXT',
#
# 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, **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
-
+ 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,
return nodeid
-def setPropArrayFromString(self, cl, propString, nodeid = None):
+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, ';'):
errors.append('not of form [arg=value,value,...;'
'arg=value,value,...]')
return (errors, props)
-
- # ensure it's a valid property name
+ # convert the value to a hyperdb-usable value
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)
+ props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+ propname, value)
+ except hyperdb.HyperdbValueError, message:
+ errors.append(message)
return errors, props
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*$'),
+ 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 ">"