Code

store PIPE messages so we can re-send them on errors
[roundup.git] / roundup / mailgw.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17 #
19 '''
20 An e-mail gateway for Roundup.
22 Incoming messages are examined for multiple parts:
23  . In a multipart/mixed message or part, each subpart is extracted and
24    examined. The text/plain subparts are assembled to form the textual
25    body of the message, to be stored in the file associated with a "msg"
26    class node. Any parts of other types are each stored in separate files
27    and given "file" class nodes that are linked to the "msg" node. 
28  . In a multipart/alternative message or part, we look for a text/plain
29    subpart and ignore the other parts.
31 Summary
32 -------
33 The "summary" property on message nodes is taken from the first non-quoting
34 section in the message body. The message body is divided into sections by
35 blank lines. Sections where the second and all subsequent lines begin with
36 a ">" or "|" character are considered "quoting sections". The first line of
37 the first non-quoting section becomes the summary of the message. 
39 Addresses
40 ---------
41 All of the addresses in the To: and Cc: headers of the incoming message are
42 looked up among the user nodes, and the corresponding users are placed in
43 the "recipients" property on the new "msg" node. The address in the From:
44 header similarly determines the "author" property of the new "msg"
45 node. The default handling for addresses that don't have corresponding
46 users is to create new users with no passwords and a username equal to the
47 address. (The web interface does not permit logins for users with no
48 passwords.) If we prefer to reject mail from outside sources, we can simply
49 register an auditor on the "user" class that prevents the creation of user
50 nodes with no passwords. 
52 Actions
53 -------
54 The subject line of the incoming message is examined to determine whether
55 the message is an attempt to create a new item or to discuss an existing
56 item. A designator enclosed in square brackets is sought as the first thing
57 on the subject line (after skipping any "Fwd:" or "Re:" prefixes). 
59 If an item designator (class name and id number) is found there, the newly
60 created "msg" node is added to the "messages" property for that item, and
61 any new "file" nodes are added to the "files" property for the item. 
63 If just an item class name is found there, we attempt to create a new item
64 of that class with its "messages" property initialized to contain the new
65 "msg" node and its "files" property initialized to contain any new "file"
66 nodes. 
68 Triggers
69 --------
70 Both cases may trigger detectors (in the first case we are calling the
71 set() method to add the message to the item's spool; in the second case we
72 are calling the create() method to create a new node). If an auditor raises
73 an exception, the original message is bounced back to the sender with the
74 explanatory message given in the exception. 
76 $Id: mailgw.py,v 1.96 2002-10-15 06:51:32 richard Exp $
77 '''
79 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
80 import time, random, sys
81 import traceback, MimeWriter
82 import hyperdb, date, password
84 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
86 class MailGWError(ValueError):
87     pass
89 class MailUsageError(ValueError):
90     pass
92 class MailUsageHelp(Exception):
93     pass
95 class Unauthorized(Exception):
96     """ Access denied """
98 def initialiseSecurity(security):
99     ''' Create some Permissions and Roles on the security object
101         This function is directly invoked by security.Security.__init__()
102         as a part of the Security object instantiation.
103     '''
104     security.addPermission(name="Email Registration",
105         description="Anonymous may register through e-mail")
106     p = security.addPermission(name="Email Access",
107         description="User may use the email interface")
108     security.addPermissionToRole('Admin', p)
110 class Message(mimetools.Message):
111     ''' subclass mimetools.Message so we can retrieve the parts of the
112         message...
113     '''
114     def getPart(self):
115         ''' Get a single part of a multipart message and return it as a new
116             Message instance.
117         '''
118         boundary = self.getparam('boundary')
119         mid, end = '--'+boundary, '--'+boundary+'--'
120         s = cStringIO.StringIO()
121         while 1:
122             line = self.fp.readline()
123             if not line:
124                 break
125             if line.strip() in (mid, end):
126                 break
127             s.write(line)
128         if not s.getvalue().strip():
129             return None
130         s.seek(0)
131         return Message(s)
133 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re|aw)\s*\W?\s*)*'
134     r'\s*(?P<quote>")?(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
135     r'\s*(?P<title>[^[]+)?"?(\[(?P<args>.+?)\])?', re.I)
137 class MailGW:
138     def __init__(self, instance, db):
139         self.instance = instance
140         self.db = db
142         # should we trap exceptions (normal usage) or pass them through
143         # (for testing)
144         self.trapExceptions = 1
146     def do_pipe(self):
147         ''' Read a message from standard input and pass it to the mail handler.
149             Read into an internal structure that we can seek on (in case
150             there's an error).
152             XXX: we may want to read this into a temporary file instead...
153         '''
154         s = cStringIO.StringIO()
155         s.write(sys.stdin.read())
156         self.main(s)
157         return 0
159     def do_mailbox(self, filename):
160         ''' Read a series of messages from the specified unix mailbox file and
161             pass each to the mail handler.
162         '''
163         # open the spool file and lock it
164         import fcntl, FCNTL
165         f = open(filename, 'r+')
166         fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
168         # handle and clear the mailbox
169         try:
170             from mailbox import UnixMailbox
171             mailbox = UnixMailbox(f, factory=Message)
172             # grab one message
173             message = mailbox.next()
174             while message:
175                 # handle this message
176                 self.handle_Message(message)
177                 message = mailbox.next()
178             # nuke the file contents
179             os.ftruncate(f.fileno(), 0)
180         except:
181             import traceback
182             traceback.print_exc()
183             return 1
184         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
185         return 0
187     def do_pop(self, server, user='', password=''):
188         '''Read a series of messages from the specified POP server.
189         '''
190         import getpass, poplib, socket
191         try:
192             if not user:
193                 user = raw_input(_('User: '))
194             if not password:
195                 password = getpass.getpass()
196         except (KeyboardInterrupt, EOFError):
197             # Ctrl C or D maybe also Ctrl Z under Windows.
198             print "\nAborted by user."
199             return 1
201         # open a connection to the server and retrieve all messages
202         try:
203             server = poplib.POP3(server)
204         except socket.error, message:
205             print "POP server error:", message
206             return 1
207         server.user(user)
208         server.pass_(password)
209         numMessages = len(server.list()[1])
210         for i in range(1, numMessages+1):
211             # retr: returns 
212             # [ pop response e.g. '+OK 459 octets',
213             #   [ array of message lines ],
214             #   number of octets ]
215             lines = server.retr(i)[1]
216             s = cStringIO.StringIO('\n'.join(lines))
217             s.seek(0)
218             self.handle_Message(Message(s))
219             # delete the message
220             server.dele(i)
222         # quit the server to commit changes.
223         server.quit()
224         return 0
226     def main(self, fp):
227         ''' fp - the file from which to read the Message.
228         '''
229         return self.handle_Message(Message(fp))
231     def handle_Message(self, message):
232         '''Handle an RFC822 Message
234         Handle the Message object by calling handle_message() and then cope
235         with any errors raised by handle_message.
236         This method's job is to make that call and handle any
237         errors in a sane manner. It should be replaced if you wish to
238         handle errors in a different manner.
239         '''
240         # in some rare cases, a particularly stuffed-up e-mail will make
241         # its way into here... try to handle it gracefully
242         sendto = message.getaddrlist('from')
243         if sendto:
244             if not self.trapExceptions:
245                 return self.handle_message(message)
246             try:
247                 return self.handle_message(message)
248             except MailUsageHelp:
249                 # bounce the message back to the sender with the usage message
250                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
251                 sendto = [sendto[0][1]]
252                 m = ['']
253                 m.append('\n\nMail Gateway Help\n=================')
254                 m.append(fulldoc)
255                 m = self.bounce_message(message, sendto, m,
256                     subject="Mail Gateway Help")
257             except MailUsageError, value:
258                 # bounce the message back to the sender with the usage message
259                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
260                 sendto = [sendto[0][1]]
261                 m = ['']
262                 m.append(str(value))
263                 m.append('\n\nMail Gateway Help\n=================')
264                 m.append(fulldoc)
265                 m = self.bounce_message(message, sendto, m)
266             except Unauthorized, value:
267                 # just inform the user that he is not authorized
268                 sendto = [sendto[0][1]]
269                 m = ['']
270                 m.append(str(value))
271                 m = self.bounce_message(message, sendto, m)
272             except:
273                 # bounce the message back to the sender with the error message
274                 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
275                 m = ['']
276                 m.append('An unexpected error occurred during the processing')
277                 m.append('of your message. The tracker administrator is being')
278                 m.append('notified.\n')
279                 m.append('----  traceback of failure  ----')
280                 s = cStringIO.StringIO()
281                 import traceback
282                 traceback.print_exc(None, s)
283                 m.append(s.getvalue())
284                 m = self.bounce_message(message, sendto, m)
285         else:
286             # very bad-looking message - we don't even know who sent it
287             sendto = [self.instance.config.ADMIN_EMAIL]
288             m = ['Subject: badly formed message from mail gateway']
289             m.append('')
290             m.append('The mail gateway retrieved a message which has no From:')
291             m.append('line, indicating that it is corrupt. Please check your')
292             m.append('mail gateway source. Failed message is attached.')
293             m.append('')
294             m = self.bounce_message(message, sendto, m,
295                 subject='Badly formed message from mail gateway')
297         # now send the message
298         if SENDMAILDEBUG:
299             open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
300                 self.instance.config.ADMIN_EMAIL, ', '.join(sendto),
301                     m.getvalue()))
302         else:
303             try:
304                 smtp = smtplib.SMTP(self.instance.config.MAILHOST)
305                 smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto,
306                     m.getvalue())
307             except socket.error, value:
308                 raise MailGWError, "Couldn't send error email: "\
309                     "mailhost %s"%value
310             except smtplib.SMTPException, value:
311                 raise MailGWError, "Couldn't send error email: %s"%value
313     def bounce_message(self, message, sendto, error,
314             subject='Failed issue tracker submission'):
315         ''' create a message that explains the reason for the failed
316             issue submission to the author and attach the original
317             message.
318         '''
319         msg = cStringIO.StringIO()
320         writer = MimeWriter.MimeWriter(msg)
321         writer.addheader('Subject', subject)
322         writer.addheader('From', '%s <%s>'% (self.instance.config.TRACKER_NAME,
323             self.instance.config.TRACKER_EMAIL))
324         writer.addheader('To', ','.join(sendto))
325         writer.addheader('MIME-Version', '1.0')
326         part = writer.startmultipartbody('mixed')
327         part = writer.nextpart()
328         body = part.startbody('text/plain')
329         body.write('\n'.join(error))
331         # reconstruct the original message
332         m = cStringIO.StringIO()
333         w = MimeWriter.MimeWriter(m)
334         # default the content_type, just in case...
335         content_type = 'text/plain'
336         # add the headers except the content-type
337         for header in message.headers:
338             header_name = header.split(':')[0]
339             if header_name.lower() == 'content-type':
340                 content_type = message.getheader(header_name)
341             elif message.getheader(header_name):
342                 w.addheader(header_name, message.getheader(header_name))
343         # now attach the message body
344         body = w.startbody(content_type)
345         try:
346             message.rewindbody()
347         except IOError, message:
348             body.write("*** couldn't include message body: %s ***"%message)
349         else:
350             body.write(message.fp.read())
352         # attach the original message to the returned message
353         part = writer.nextpart()
354         part.addheader('Content-Disposition','attachment')
355         part.addheader('Content-Description','Message you sent')
356         part.addheader('Content-Transfer-Encoding', '7bit')
357         body = part.startbody('message/rfc822')
358         body.write(m.getvalue())
360         writer.lastpart()
361         return msg
363     def get_part_data_decoded(self,part):
364         encoding = part.getencoding()
365         data = None
366         if encoding == 'base64':
367             # BUG: is base64 really used for text encoding or
368             # are we inserting zip files here. 
369             data = binascii.a2b_base64(part.fp.read())
370         elif encoding == 'quoted-printable':
371             # the quopri module wants to work with files
372             decoded = cStringIO.StringIO()
373             quopri.decode(part.fp, decoded)
374             data = decoded.getvalue()
375         elif encoding == 'uuencoded':
376             data = binascii.a2b_uu(part.fp.read())
377         else:
378             # take it as text
379             data = part.fp.read()
380         return data
382     def handle_message(self, message):
383         ''' message - a Message instance
385         Parse the message as per the module docstring.
386         '''
387         # handle the subject line
388         subject = message.getheader('subject', '')
390         if subject.strip() == 'help':
391             raise MailUsageHelp
393         m = subject_re.match(subject)
395         # check for well-formed subject line
396         if m:
397             # get the classname
398             classname = m.group('classname')
399             if classname is None:
400                 # no classname, fallback on the default
401                 if hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
402                         self.instance.config.MAIL_DEFAULT_CLASS:
403                     classname = self.instance.config.MAIL_DEFAULT_CLASS
404                 else:
405                     # fail
406                     m = None
408         if not m:
409             raise MailUsageError, '''
410 The message you sent to roundup did not contain a properly formed subject
411 line. The subject must contain a class name or designator to indicate the
412 "topic" of the message. For example:
413     Subject: [issue] This is a new issue
414       - this will create a new issue in the tracker with the title "This is
415         a new issue".
416     Subject: [issue1234] This is a followup to issue 1234
417       - this will append the message's contents to the existing issue 1234
418         in the tracker.
420 Subject was: "%s"
421 '''%subject
423         # get the class
424         try:
425             cl = self.db.getclass(classname)
426         except KeyError:
427             raise MailUsageError, '''
428 The class name you identified in the subject line ("%s") does not exist in the
429 database.
431 Valid class names are: %s
432 Subject was: "%s"
433 '''%(classname, ', '.join(self.db.getclasses()), subject)
435         # get the optional nodeid
436         nodeid = m.group('nodeid')
438         # title is optional too
439         title = m.group('title')
440         if title:
441             title = title.strip()
442         else:
443             title = ''
445         # strip off the quotes that dumb emailers put around the subject, like
446         #      Re: "[issue1] bla blah"
447         if m.group('quote') and title.endswith('"'):
448             title = title[:-1]
450         # but we do need either a title or a nodeid...
451         if nodeid is None and not title:
452             raise MailUsageError, '''
453 I cannot match your message to a node in the database - you need to either
454 supply a full node identifier (with number, eg "[issue123]" or keep the
455 previous subject title intact so I can match that.
457 Subject was: "%s"
458 '''%subject
460         # If there's no nodeid, check to see if this is a followup and
461         # maybe someone's responded to the initial mail that created an
462         # entry. Try to find the matching nodes with the same title, and
463         # use the _last_ one matched (since that'll _usually_ be the most
464         # recent...)
465         if nodeid is None and m.group('refwd'):
466             l = cl.stringFind(title=title)
467             if l:
468                 nodeid = l[-1]
470         # if a nodeid was specified, make sure it's valid
471         if nodeid is not None and not cl.hasnode(nodeid):
472             raise MailUsageError, '''
473 The node specified by the designator in the subject of your message ("%s")
474 does not exist.
476 Subject was: "%s"
477 '''%(nodeid, subject)
479         #
480         # handle the users
481         #
482         # Don't create users if anonymous isn't allowed to register
483         create = 1
484         anonid = self.db.user.lookup('anonymous')
485         if not self.db.security.hasPermission('Email Registration', anonid):
486             create = 0
488         # ok, now figure out who the author is - create a new user if the
489         # "create" flag is true
490         author = uidFromAddress(self.db, message.getaddrlist('from')[0],
491             create=create)
493         # if we're not recognised, and we don't get added as a user, then we
494         # must be anonymous
495         if not author:
496             author = anonid
498         # make sure the author has permission to use the email interface
499         if not self.db.security.hasPermission('Email Access', author):
500             if author == anonid:
501                 # we're anonymous and we need to be a registered user
502                 raise Unauthorized, '''
503 You are not a registered user.
505 Unknown address: %s
506 '''%message.getaddrlist('from')[0][1]
507             else:
508                 # we're registered and we're _still_ not allowed access
509                 raise Unauthorized, 'You are not permitted to access '\
510                     'this tracker.'
512         # make sure they're allowed to edit this class of information
513         if not self.db.security.hasPermission('Edit', author, classname):
514             raise Unauthorized, 'You are not permitted to edit %s.'%classname
516         # the author may have been created - make sure the change is
517         # committed before we reopen the database
518         self.db.commit()
520         # reopen the database as the author
521         username = self.db.user.get(author, 'username')
522         self.db.close()
523         self.db = self.instance.open(username)
525         # re-get the class with the new database connection
526         cl = self.db.getclass(classname)
528         # now update the recipients list
529         recipients = []
530         tracker_email = self.instance.config.TRACKER_EMAIL.lower()
531         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
532             r = recipient[1].strip().lower()
533             if r == tracker_email or not r:
534                 continue
536             # look up the recipient - create if necessary (and we're
537             # allowed to)
538             recipient = uidFromAddress(self.db, recipient, create)
540             # if all's well, add the recipient to the list
541             if recipient:
542                 recipients.append(recipient)
544         #
545         # extract the args
546         #
547         subject_args = m.group('args')
549         #
550         # handle the subject argument list
551         #
552         # figure what the properties of this Class are
553         properties = cl.getprops()
554         props = {}
555         args = m.group('args')
556         if args:
557             errors = []
558             for prop in string.split(args, ';'):
559                 # extract the property name and value
560                 try:
561                     propname, value = prop.split('=')
562                 except ValueError, message:
563                     errors.append('not of form [arg=value,'
564                         'value,...;arg=value,value...]')
565                     break
567                 # ensure it's a valid property name
568                 propname = propname.strip()
569                 try:
570                     proptype =  properties[propname]
571                 except KeyError:
572                     errors.append('refers to an invalid property: '
573                         '"%s"'%propname)
574                     continue
576                 # convert the string value to a real property value
577                 if isinstance(proptype, hyperdb.String):
578                     props[propname] = value.strip()
579                 if isinstance(proptype, hyperdb.Password):
580                     props[propname] = password.Password(value.strip())
581                 elif isinstance(proptype, hyperdb.Date):
582                     try:
583                         props[propname] = date.Date(value.strip())
584                     except ValueError, message:
585                         errors.append('contains an invalid date for '
586                             '%s.'%propname)
587                 elif isinstance(proptype, hyperdb.Interval):
588                     try:
589                         props[propname] = date.Interval(value)
590                     except ValueError, message:
591                         errors.append('contains an invalid date interval'
592                             'for %s.'%propname)
593                 elif isinstance(proptype, hyperdb.Link):
594                     linkcl = self.db.classes[proptype.classname]
595                     propkey = linkcl.labelprop(default_to_id=1)
596                     try:
597                         props[propname] = linkcl.lookup(value)
598                     except KeyError, message:
599                         errors.append('"%s" is not a value for %s.'%(value,
600                             propname))
601                 elif isinstance(proptype, hyperdb.Multilink):
602                     # get the linked class
603                     linkcl = self.db.classes[proptype.classname]
604                     propkey = linkcl.labelprop(default_to_id=1)
605                     if nodeid:
606                         curvalue = cl.get(nodeid, propname)
607                     else:
608                         curvalue = []
610                     # handle each add/remove in turn
611                     # keep an extra list for all items that are
612                     # definitely in the new list (in case of e.g.
613                     # <propname>=A,+B, which should replace the old
614                     # list with A,B)
615                     set = 0
616                     newvalue = []
617                     for item in value.split(','):
618                         item = item.strip()
620                         # handle +/-
621                         remove = 0
622                         if item.startswith('-'):
623                             remove = 1
624                             item = item[1:]
625                         elif item.startswith('+'):
626                             item = item[1:]
627                         else:
628                             set = 1
630                         # look up the value
631                         try:
632                             item = linkcl.lookup(item)
633                         except KeyError, message:
634                             errors.append('"%s" is not a value for %s.'%(item,
635                                 propname))
636                             continue
638                         # perform the add/remove
639                         if remove:
640                             try:
641                                 curvalue.remove(item)
642                             except ValueError:
643                                 errors.append('"%s" is not currently in '
644                                     'for %s.'%(item, propname))
645                                 continue
646                         else:
647                             newvalue.append(item)
648                             if item not in curvalue:
649                                 curvalue.append(item)
651                     # that's it, set the new Multilink property value,
652                     # or overwrite it completely
653                     if set:
654                         props[propname] = newvalue
655                     else:
656                         props[propname] = curvalue
657                 elif isinstance(proptype, hyperdb.Boolean):
658                     value = value.strip()
659                     props[propname] = value.lower() in ('yes', 'true', 'on', '1')
660                 elif isinstance(proptype, hyperdb.Number):
661                     value = value.strip()
662                     props[propname] = int(value)
664             # handle any errors parsing the argument list
665             if errors:
666                 errors = '\n- '.join(errors)
667                 raise MailUsageError, '''
668 There were problems handling your subject line argument list:
669 - %s
671 Subject was: "%s"
672 '''%(errors, subject)
674         #
675         # handle message-id and in-reply-to
676         #
677         messageid = message.getheader('message-id')
678         inreplyto = message.getheader('in-reply-to') or ''
679         # generate a messageid if there isn't one
680         if not messageid:
681             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
682                 classname, nodeid, self.instance.config.MAIL_DOMAIN)
684         #
685         # now handle the body - find the message
686         #
687         content_type =  message.gettype()
688         attachments = []
689         # General multipart handling:
690         #   Take the first text/plain part, anything else is considered an 
691         #   attachment.
692         # multipart/mixed: multiple "unrelated" parts.
693         # multipart/signed (rfc 1847): 
694         #   The control information is carried in the second of the two 
695         #   required body parts.
696         #   ACTION: Default, so if content is text/plain we get it.
697         # multipart/encrypted (rfc 1847): 
698         #   The control information is carried in the first of the two 
699         #   required body parts.
700         #   ACTION: Not handleable as the content is encrypted.
701         # multipart/related (rfc 1872, 2112, 2387):
702         #   The Multipart/Related content-type addresses the MIME
703         #   representation of compound objects.
704         #   ACTION: Default. If we are lucky there is a text/plain.
705         #   TODO: One should use the start part and look for an Alternative
706         #   that is text/plain.
707         # multipart/Alternative (rfc 1872, 1892):
708         #   only in "related" ?
709         # multipart/report (rfc 1892):
710         #   e.g. mail system delivery status reports.
711         #   ACTION: Default. Could be ignored or used for Delivery Notification 
712         #   flagging.
713         # multipart/form-data:
714         #   For web forms only.
715         if content_type == 'multipart/mixed':
716             # skip over the intro to the first boundary
717             part = message.getPart()
718             content = None
719             while 1:
720                 # get the next part
721                 part = message.getPart()
722                 if part is None:
723                     break
724                 # parse it
725                 subtype = part.gettype()
726                 if subtype == 'text/plain' and not content:
727                     # The first text/plain part is the message content.
728                     content = self.get_part_data_decoded(part) 
729                 elif subtype == 'message/rfc822':
730                     # handle message/rfc822 specially - the name should be
731                     # the subject of the actual e-mail embedded here
732                     i = part.fp.tell()
733                     mailmess = Message(part.fp)
734                     name = mailmess.getheader('subject')
735                     part.fp.seek(i)
736                     attachments.append((name, 'message/rfc822', part.fp.read()))
737                 else:
738                     # try name on Content-Type
739                     name = part.getparam('name')
740                     # this is just an attachment
741                     data = self.get_part_data_decoded(part) 
742                     attachments.append((name, part.gettype(), data))
743             if content is None:
744                 raise MailUsageError, '''
745 Roundup requires the submission to be plain text. The message parser could
746 not find a text/plain part to use.
747 '''
749         elif content_type[:10] == 'multipart/':
750             # skip over the intro to the first boundary
751             message.getPart()
752             content = None
753             while 1:
754                 # get the next part
755                 part = message.getPart()
756                 if part is None:
757                     break
758                 # parse it
759                 if part.gettype() == 'text/plain' and not content:
760                     content = self.get_part_data_decoded(part) 
761             if content is None:
762                 raise MailUsageError, '''
763 Roundup requires the submission to be plain text. The message parser could
764 not find a text/plain part to use.
765 '''
767         elif content_type != 'text/plain':
768             raise MailUsageError, '''
769 Roundup requires the submission to be plain text. The message parser could
770 not find a text/plain part to use.
771 '''
773         else:
774             content = self.get_part_data_decoded(message) 
775  
776         # figure how much we should muck around with the email body
777         keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT',
778             'no') == 'yes'
779         keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED',
780             'no') == 'yes'
782         # parse the body of the message, stripping out bits as appropriate
783         summary, content = parseContent(content, keep_citations, 
784             keep_body)
786         # 
787         # handle the attachments
788         #
789         files = []
790         for (name, mime_type, data) in attachments:
791             if not name:
792                 name = "unnamed"
793             files.append(self.db.file.create(type=mime_type, name=name,
794                 content=data))
796         # 
797         # create the message if there's a message body (content)
798         #
799         if content:
800             message_id = self.db.msg.create(author=author,
801                 recipients=recipients, date=date.Date('.'), summary=summary,
802                 content=content, files=files, messageid=messageid,
803                 inreplyto=inreplyto)
805             # attach the message to the node
806             if nodeid:
807                 # add the message to the node's list
808                 messages = cl.get(nodeid, 'messages')
809                 messages.append(message_id)
810                 props['messages'] = messages
811             else:
812                 # pre-load the messages list
813                 props['messages'] = [message_id]
815                 # set the title to the subject
816                 if properties.has_key('title') and not props.has_key('title'):
817                     props['title'] = title
819         #
820         # perform the node change / create
821         #
822         try:
823             if nodeid:
824                 cl.set(nodeid, **props)
825             else:
826                 nodeid = cl.create(**props)
827         except (TypeError, IndexError, ValueError), message:
828             raise MailUsageError, '''
829 There was a problem with the message you sent:
830    %s
831 '''%message
833         # commit the changes to the DB
834         self.db.commit()
836         return nodeid
838 def extractUserFromList(userClass, users):
839     '''Given a list of users, try to extract the first non-anonymous user
840        and return that user, otherwise return None
841     '''
842     if len(users) > 1:
843         for user in users:
844             # make sure we don't match the anonymous or admin user
845             if userClass.get(user, 'username') in ('admin', 'anonymous'):
846                 continue
847             # first valid match will do
848             return user
849         # well, I guess we have no choice
850         return user[0]
851     elif users:
852         return users[0]
853     return None
855 def uidFromAddress(db, address, create=1):
856     ''' address is from the rfc822 module, and therefore is (name, addr)
858         user is created if they don't exist in the db already
859     '''
860     (realname, address) = address
862     # try a straight match of the address
863     user = extractUserFromList(db.user, db.user.stringFind(address=address))
864     if user is not None: return user
866     # try the user alternate addresses if possible
867     props = db.user.getprops()
868     if props.has_key('alternate_addresses'):
869         users = db.user.filter(None, {'alternate_addresses': address})
870         user = extractUserFromList(db.user, users)
871         if user is not None: return user
873     # try to match the username to the address (for local
874     # submissions where the address is empty)
875     user = extractUserFromList(db.user, db.user.stringFind(username=address))
877     # couldn't match address or username, so create a new user
878     if create:
879         return db.user.create(username=address, address=address,
880             realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES)
881     else:
882         return 0
884 def parseContent(content, keep_citations, keep_body,
885         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
886         eol=re.compile(r'[\r\n]+'), 
887         signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
888         original_message=re.compile(r'^[>|\s]*-----Original Message-----$')):
889     ''' The message body is divided into sections by blank lines.
890         Sections where the second and all subsequent lines begin with a ">"
891         or "|" character are considered "quoting sections". The first line of
892         the first non-quoting section becomes the summary of the message. 
894         If keep_citations is true, then we keep the "quoting sections" in the
895         content.
896         If keep_body is true, we even keep the signature sections.
897     '''
898     # strip off leading carriage-returns / newlines
899     i = 0
900     for i in range(len(content)):
901         if content[i] not in '\r\n':
902             break
903     if i > 0:
904         sections = blank_line.split(content[i:])
905     else:
906         sections = blank_line.split(content)
908     # extract out the summary from the message
909     summary = ''
910     l = []
911     for section in sections:
912         #section = section.strip()
913         if not section:
914             continue
915         lines = eol.split(section)
916         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
917                 lines[1] and lines[1][0] in '>|'):
918             # see if there's a response somewhere inside this section (ie.
919             # no blank line between quoted message and response)
920             for line in lines[1:]:
921                 if line and line[0] not in '>|':
922                     break
923             else:
924                 # we keep quoted bits if specified in the config
925                 if keep_citations:
926                     l.append(section)
927                 continue
928             # keep this section - it has reponse stuff in it
929             if not summary:
930                 # and while we're at it, use the first non-quoted bit as
931                 # our summary
932                 summary = line
933             lines = lines[lines.index(line):]
934             section = '\n'.join(lines)
936         if not summary:
937             # if we don't have our summary yet use the first line of this
938             # section
939             summary = lines[0]
940         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
941             # lose any signature
942             break
943         elif original_message.match(lines[0]):
944             # ditch the stupid Outlook quoting of the entire original message
945             break
947         # and add the section to the output
948         l.append(section)
950     # Now reconstitute the message content minus the bits we don't care
951     # about.
952     if not keep_body:
953         content = '\n\n'.join(l)
955     return summary, content
957 # vim: set filetype=python ts=4 sw=4 et si