Code

fixed the mailgw so that anonymous users may still access it
[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 __doc__ = '''
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.93 2002-09-26 22:15:54 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.
148         '''
149         self.main(sys.stdin)
150         return 0
152     def do_mailbox(self, filename):
153         ''' Read a series of messages from the specified unix mailbox file and
154             pass each to the mail handler.
155         '''
156         # open the spool file and lock it
157         import fcntl, FCNTL
158         f = open(filename, 'r+')
159         fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
161         # handle and clear the mailbox
162         try:
163             from mailbox import UnixMailbox
164             mailbox = UnixMailbox(f, factory=Message)
165             # grab one message
166             message = mailbox.next()
167             while message:
168                 # handle this message
169                 self.handle_Message(message)
170                 message = mailbox.next()
171             # nuke the file contents
172             os.ftruncate(f.fileno(), 0)
173         except:
174             import traceback
175             traceback.print_exc()
176             return 1
177         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
178         return 0
180     def do_pop(self, server, user='', password=''):
181         '''Read a series of messages from the specified POP server.
182         '''
183         import getpass, poplib, socket
184         try:
185             if not user:
186                 user = raw_input(_('User: '))
187             if not password:
188                 password = getpass.getpass()
189         except (KeyboardInterrupt, EOFError):
190             # Ctrl C or D maybe also Ctrl Z under Windows.
191             print "\nAborted by user."
192             return 1
194         # open a connection to the server and retrieve all messages
195         try:
196             server = poplib.POP3(server)
197         except socket.error, message:
198             print "POP server error:", message
199             return 1
200         server.user(user)
201         server.pass_(password)
202         numMessages = len(server.list()[1])
203         for i in range(1, numMessages+1):
204             # retr: returns 
205             # [ pop response e.g. '+OK 459 octets',
206             #   [ array of message lines ],
207             #   number of octets ]
208             lines = server.retr(i)[1]
209             s = cStringIO.StringIO('\n'.join(lines))
210             s.seek(0)
211             self.handle_Message(Message(s))
212             # delete the message
213             server.dele(i)
215         # quit the server to commit changes.
216         server.quit()
217         return 0
219     def main(self, fp):
220         ''' fp - the file from which to read the Message.
221         '''
222         return self.handle_Message(Message(fp))
224     def handle_Message(self, message):
225         '''Handle an RFC822 Message
227         Handle the Message object by calling handle_message() and then cope
228         with any errors raised by handle_message.
229         This method's job is to make that call and handle any
230         errors in a sane manner. It should be replaced if you wish to
231         handle errors in a different manner.
232         '''
233         # in some rare cases, a particularly stuffed-up e-mail will make
234         # its way into here... try to handle it gracefully
235         sendto = message.getaddrlist('from')
236         if sendto:
237             if not self.trapExceptions:
238                 return self.handle_message(message)
239             try:
240                 return self.handle_message(message)
241             except MailUsageHelp:
242                 # bounce the message back to the sender with the usage message
243                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
244                 sendto = [sendto[0][1]]
245                 m = ['']
246                 m.append('\n\nMail Gateway Help\n=================')
247                 m.append(fulldoc)
248                 m = self.bounce_message(message, sendto, m,
249                     subject="Mail Gateway Help")
250             except MailUsageError, value:
251                 # bounce the message back to the sender with the usage message
252                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
253                 sendto = [sendto[0][1]]
254                 m = ['']
255                 m.append(str(value))
256                 m.append('\n\nMail Gateway Help\n=================')
257                 m.append(fulldoc)
258                 m = self.bounce_message(message, sendto, m)
259             except Unauthorized, value:
260                 # just inform the user that he is not authorized
261                 sendto = [sendto[0][1]]
262                 m = ['']
263                 m.append(str(value))
264                 m = self.bounce_message(message, sendto, m)
265             except:
266                 # bounce the message back to the sender with the error message
267                 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
268                 m = ['']
269                 m.append('An unexpected error occurred during the processing')
270                 m.append('of your message. The tracker administrator is being')
271                 m.append('notified.\n')
272                 m.append('----  traceback of failure  ----')
273                 s = cStringIO.StringIO()
274                 import traceback
275                 traceback.print_exc(None, s)
276                 m.append(s.getvalue())
277                 m = self.bounce_message(message, sendto, m)
278         else:
279             # very bad-looking message - we don't even know who sent it
280             sendto = [self.instance.config.ADMIN_EMAIL]
281             m = ['Subject: badly formed message from mail gateway']
282             m.append('')
283             m.append('The mail gateway retrieved a message which has no From:')
284             m.append('line, indicating that it is corrupt. Please check your')
285             m.append('mail gateway source. Failed message is attached.')
286             m.append('')
287             m = self.bounce_message(message, sendto, m,
288                 subject='Badly formed message from mail gateway')
290         # now send the message
291         if SENDMAILDEBUG:
292             open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
293                 self.instance.config.ADMIN_EMAIL, ', '.join(sendto),
294                     m.getvalue()))
295         else:
296             try:
297                 smtp = smtplib.SMTP(self.instance.config.MAILHOST)
298                 smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto,
299                     m.getvalue())
300             except socket.error, value:
301                 raise MailGWError, "Couldn't send error email: "\
302                     "mailhost %s"%value
303             except smtplib.SMTPException, value:
304                 raise MailGWError, "Couldn't send error email: %s"%value
306     def bounce_message(self, message, sendto, error,
307             subject='Failed issue tracker submission'):
308         ''' create a message that explains the reason for the failed
309             issue submission to the author and attach the original
310             message.
311         '''
312         msg = cStringIO.StringIO()
313         writer = MimeWriter.MimeWriter(msg)
314         writer.addheader('Subject', subject)
315         writer.addheader('From', '%s <%s>'% (self.instance.config.TRACKER_NAME,
316             self.instance.config.TRACKER_EMAIL))
317         writer.addheader('To', ','.join(sendto))
318         writer.addheader('MIME-Version', '1.0')
319         part = writer.startmultipartbody('mixed')
320         part = writer.nextpart()
321         body = part.startbody('text/plain')
322         body.write('\n'.join(error))
324         # reconstruct the original message
325         m = cStringIO.StringIO()
326         w = MimeWriter.MimeWriter(m)
327         # default the content_type, just in case...
328         content_type = 'text/plain'
329         # add the headers except the content-type
330         for header in message.headers:
331             header_name = header.split(':')[0]
332             if header_name.lower() == 'content-type':
333                 content_type = message.getheader(header_name)
334             elif message.getheader(header_name):
335                 w.addheader(header_name, message.getheader(header_name))
336         # now attach the message body
337         body = w.startbody(content_type)
338         try:
339             message.rewindbody()
340         except IOError:
341             body.write("*** couldn't include message body: read from pipe ***")
342         else:
343             body.write(message.fp.read())
345         # attach the original message to the returned message
346         part = writer.nextpart()
347         part.addheader('Content-Disposition','attachment')
348         part.addheader('Content-Description','Message you sent')
349         part.addheader('Content-Transfer-Encoding', '7bit')
350         body = part.startbody('message/rfc822')
351         body.write(m.getvalue())
353         writer.lastpart()
354         return msg
356     def get_part_data_decoded(self,part):
357         encoding = part.getencoding()
358         data = None
359         if encoding == 'base64':
360             # BUG: is base64 really used for text encoding or
361             # are we inserting zip files here. 
362             data = binascii.a2b_base64(part.fp.read())
363         elif encoding == 'quoted-printable':
364             # the quopri module wants to work with files
365             decoded = cStringIO.StringIO()
366             quopri.decode(part.fp, decoded)
367             data = decoded.getvalue()
368         elif encoding == 'uuencoded':
369             data = binascii.a2b_uu(part.fp.read())
370         else:
371             # take it as text
372             data = part.fp.read()
373         return data
375     def handle_message(self, message):
376         ''' message - a Message instance
378         Parse the message as per the module docstring.
379         '''
380         # handle the subject line
381         subject = message.getheader('subject', '')
383         if subject.strip() == 'help':
384             raise MailUsageHelp
386         m = subject_re.match(subject)
388         # check for well-formed subject line
389         if m:
390             # get the classname
391             classname = m.group('classname')
392             if classname is None:
393                 # no classname, fallback on the default
394                 if hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
395                         self.instance.config.MAIL_DEFAULT_CLASS:
396                     classname = self.instance.config.MAIL_DEFAULT_CLASS
397                 else:
398                     # fail
399                     m = None
401         if not m:
402             raise MailUsageError, '''
403 The message you sent to roundup did not contain a properly formed subject
404 line. The subject must contain a class name or designator to indicate the
405 "topic" of the message. For example:
406     Subject: [issue] This is a new issue
407       - this will create a new issue in the tracker with the title "This is
408         a new issue".
409     Subject: [issue1234] This is a followup to issue 1234
410       - this will append the message's contents to the existing issue 1234
411         in the tracker.
413 Subject was: "%s"
414 '''%subject
416         # get the class
417         try:
418             cl = self.db.getclass(classname)
419         except KeyError:
420             raise MailUsageError, '''
421 The class name you identified in the subject line ("%s") does not exist in the
422 database.
424 Valid class names are: %s
425 Subject was: "%s"
426 '''%(classname, ', '.join(self.db.getclasses()), subject)
428         # get the optional nodeid
429         nodeid = m.group('nodeid')
431         # title is optional too
432         title = m.group('title')
433         if title:
434             title = title.strip()
435         else:
436             title = ''
438         # strip off the quotes that dumb emailers put around the subject, like
439         #      Re: "[issue1] bla blah"
440         if m.group('quote') and title.endswith('"'):
441             title = title[:-1]
443         # but we do need either a title or a nodeid...
444         if nodeid is None and not title:
445             raise MailUsageError, '''
446 I cannot match your message to a node in the database - you need to either
447 supply a full node identifier (with number, eg "[issue123]" or keep the
448 previous subject title intact so I can match that.
450 Subject was: "%s"
451 '''%subject
453         # If there's no nodeid, check to see if this is a followup and
454         # maybe someone's responded to the initial mail that created an
455         # entry. Try to find the matching nodes with the same title, and
456         # use the _last_ one matched (since that'll _usually_ be the most
457         # recent...)
458         if nodeid is None and m.group('refwd'):
459             l = cl.stringFind(title=title)
460             if l:
461                 nodeid = l[-1]
463         # if a nodeid was specified, make sure it's valid
464         if nodeid is not None and not cl.hasnode(nodeid):
465             raise MailUsageError, '''
466 The node specified by the designator in the subject of your message ("%s")
467 does not exist.
469 Subject was: "%s"
470 '''%(nodeid, subject)
472         #
473         # handle the users
474         #
475         # Don't create users if anonymous isn't allowed to register
476         create = 1
477         anonid = self.db.user.lookup('anonymous')
478         if not self.db.security.hasPermission('Email Registration', anonid):
479             create = 0
481         # ok, now figure out who the author is - create a new user if the
482         # "create" flag is true
483         author = uidFromAddress(self.db, message.getaddrlist('from')[0],
484             create=create)
486         # if we're not recognised, and we don't get added as a user, then we
487         # must be anonymous
488         if not author:
489             author = anonid
491         # make sure the author has permission to use the email interface
492         if not self.db.security.hasPermission('Email Access', author):
493             if author == anonid:
494                 # we're anonymous and we need to be a registered user
495                 raise Unauthorized, '''
496 You are not a registered user.
498 Unknown address: %s
499 '''%message.getaddrlist('from')[0][1]
500             else:
501                 # we're registered and we're _still_ not allowed access
502                 raise Unauthorized, 'You are not permitted to access '\
503                     'this tracker.'
505         # make sure they're allowed to edit this class of information
506         if not self.db.security.hasPermission('Edit', author, classname):
507             raise Unauthorized, 'You are not permitted to edit %s.'%classname
509         # the author may have been created - make sure the change is
510         # committed before we reopen the database
511         self.db.commit()
513         # reopen the database as the author
514         username = self.db.user.get(author, 'username')
515         self.db.close()
516         self.db = self.instance.open(username)
518         # re-get the class with the new database connection
519         cl = self.db.getclass(classname)
521         # now update the recipients list
522         recipients = []
523         tracker_email = self.instance.config.TRACKER_EMAIL.lower()
524         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
525             r = recipient[1].strip().lower()
526             if r == tracker_email or not r:
527                 continue
529             # look up the recipient - create if necessary (and we're
530             # allowed to)
531             recipient = uidFromAddress(self.db, recipient, create)
533             # if all's well, add the recipient to the list
534             if recipient:
535                 recipients.append(recipient)
537         #
538         # extract the args
539         #
540         subject_args = m.group('args')
542         #
543         # handle the subject argument list
544         #
545         # figure what the properties of this Class are
546         properties = cl.getprops()
547         props = {}
548         args = m.group('args')
549         if args:
550             errors = []
551             for prop in string.split(args, ';'):
552                 # extract the property name and value
553                 try:
554                     propname, value = prop.split('=')
555                 except ValueError, message:
556                     errors.append('not of form [arg=value,'
557                         'value,...;arg=value,value...]')
558                     break
560                 # ensure it's a valid property name
561                 propname = propname.strip()
562                 try:
563                     proptype =  properties[propname]
564                 except KeyError:
565                     errors.append('refers to an invalid property: '
566                         '"%s"'%propname)
567                     continue
569                 # convert the string value to a real property value
570                 if isinstance(proptype, hyperdb.String):
571                     props[propname] = value.strip()
572                 if isinstance(proptype, hyperdb.Password):
573                     props[propname] = password.Password(value.strip())
574                 elif isinstance(proptype, hyperdb.Date):
575                     try:
576                         props[propname] = date.Date(value.strip())
577                     except ValueError, message:
578                         errors.append('contains an invalid date for '
579                             '%s.'%propname)
580                 elif isinstance(proptype, hyperdb.Interval):
581                     try:
582                         props[propname] = date.Interval(value)
583                     except ValueError, message:
584                         errors.append('contains an invalid date interval'
585                             'for %s.'%propname)
586                 elif isinstance(proptype, hyperdb.Link):
587                     linkcl = self.db.classes[proptype.classname]
588                     propkey = linkcl.labelprop(default_to_id=1)
589                     try:
590                         props[propname] = linkcl.lookup(value)
591                     except KeyError, message:
592                         errors.append('"%s" is not a value for %s.'%(value,
593                             propname))
594                 elif isinstance(proptype, hyperdb.Multilink):
595                     # get the linked class
596                     linkcl = self.db.classes[proptype.classname]
597                     propkey = linkcl.labelprop(default_to_id=1)
598                     if nodeid:
599                         curvalue = cl.get(nodeid, propname)
600                     else:
601                         curvalue = []
603                     # handle each add/remove in turn
604                     # keep an extra list for all items that are
605                     # definitely in the new list (in case of e.g.
606                     # <propname>=A,+B, which should replace the old
607                     # list with A,B)
608                     set = 0
609                     newvalue = []
610                     for item in value.split(','):
611                         item = item.strip()
613                         # handle +/-
614                         remove = 0
615                         if item.startswith('-'):
616                             remove = 1
617                             item = item[1:]
618                         elif item.startswith('+'):
619                             item = item[1:]
620                         else:
621                             set = 1
623                         # look up the value
624                         try:
625                             item = linkcl.lookup(item)
626                         except KeyError, message:
627                             errors.append('"%s" is not a value for %s.'%(item,
628                                 propname))
629                             continue
631                         # perform the add/remove
632                         if remove:
633                             try:
634                                 curvalue.remove(item)
635                             except ValueError:
636                                 errors.append('"%s" is not currently in '
637                                     'for %s.'%(item, propname))
638                                 continue
639                         else:
640                             newvalue.append(item)
641                             if item not in curvalue:
642                                 curvalue.append(item)
644                     # that's it, set the new Multilink property value,
645                     # or overwrite it completely
646                     if set:
647                         props[propname] = newvalue
648                     else:
649                         props[propname] = curvalue
650                 elif isinstance(proptype, hyperdb.Boolean):
651                     value = value.strip()
652                     props[propname] = value.lower() in ('yes', 'true', 'on', '1')
653                 elif isinstance(proptype, hyperdb.Number):
654                     value = value.strip()
655                     props[propname] = int(value)
657             # handle any errors parsing the argument list
658             if errors:
659                 errors = '\n- '.join(errors)
660                 raise MailUsageError, '''
661 There were problems handling your subject line argument list:
662 - %s
664 Subject was: "%s"
665 '''%(errors, subject)
667         #
668         # handle message-id and in-reply-to
669         #
670         messageid = message.getheader('message-id')
671         inreplyto = message.getheader('in-reply-to') or ''
672         # generate a messageid if there isn't one
673         if not messageid:
674             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
675                 classname, nodeid, self.instance.config.MAIL_DOMAIN)
677         #
678         # now handle the body - find the message
679         #
680         content_type =  message.gettype()
681         attachments = []
682         # General multipart handling:
683         #   Take the first text/plain part, anything else is considered an 
684         #   attachment.
685         # multipart/mixed: multiple "unrelated" parts.
686         # multipart/signed (rfc 1847): 
687         #   The control information is carried in the second of the two 
688         #   required body parts.
689         #   ACTION: Default, so if content is text/plain we get it.
690         # multipart/encrypted (rfc 1847): 
691         #   The control information is carried in the first of the two 
692         #   required body parts.
693         #   ACTION: Not handleable as the content is encrypted.
694         # multipart/related (rfc 1872, 2112, 2387):
695         #   The Multipart/Related content-type addresses the MIME
696         #   representation of compound objects.
697         #   ACTION: Default. If we are lucky there is a text/plain.
698         #   TODO: One should use the start part and look for an Alternative
699         #   that is text/plain.
700         # multipart/Alternative (rfc 1872, 1892):
701         #   only in "related" ?
702         # multipart/report (rfc 1892):
703         #   e.g. mail system delivery status reports.
704         #   ACTION: Default. Could be ignored or used for Delivery Notification 
705         #   flagging.
706         # multipart/form-data:
707         #   For web forms only.
708         if content_type == 'multipart/mixed':
709             # skip over the intro to the first boundary
710             part = message.getPart()
711             content = None
712             while 1:
713                 # get the next part
714                 part = message.getPart()
715                 if part is None:
716                     break
717                 # parse it
718                 subtype = part.gettype()
719                 if subtype == 'text/plain' and not content:
720                     # The first text/plain part is the message content.
721                     content = self.get_part_data_decoded(part) 
722                 elif subtype == 'message/rfc822':
723                     # handle message/rfc822 specially - the name should be
724                     # the subject of the actual e-mail embedded here
725                     i = part.fp.tell()
726                     mailmess = Message(part.fp)
727                     name = mailmess.getheader('subject')
728                     part.fp.seek(i)
729                     attachments.append((name, 'message/rfc822', part.fp.read()))
730                 else:
731                     # try name on Content-Type
732                     name = part.getparam('name')
733                     # this is just an attachment
734                     data = self.get_part_data_decoded(part) 
735                     attachments.append((name, part.gettype(), data))
736             if content is None:
737                 raise MailUsageError, '''
738 Roundup requires the submission to be plain text. The message parser could
739 not find a text/plain part to use.
740 '''
742         elif content_type[:10] == 'multipart/':
743             # skip over the intro to the first boundary
744             message.getPart()
745             content = None
746             while 1:
747                 # get the next part
748                 part = message.getPart()
749                 if part is None:
750                     break
751                 # parse it
752                 if part.gettype() == 'text/plain' and not content:
753                     content = self.get_part_data_decoded(part) 
754             if content is None:
755                 raise MailUsageError, '''
756 Roundup requires the submission to be plain text. The message parser could
757 not find a text/plain part to use.
758 '''
760         elif content_type != 'text/plain':
761             raise MailUsageError, '''
762 Roundup requires the submission to be plain text. The message parser could
763 not find a text/plain part to use.
764 '''
766         else:
767             content = self.get_part_data_decoded(message) 
768  
769         # figure how much we should muck around with the email body
770         keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT',
771             'no') == 'yes'
772         keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED',
773             'no') == 'yes'
775         # parse the body of the message, stripping out bits as appropriate
776         summary, content = parseContent(content, keep_citations, 
777             keep_body)
779         # 
780         # handle the attachments
781         #
782         files = []
783         for (name, mime_type, data) in attachments:
784             if not name:
785                 name = "unnamed"
786             files.append(self.db.file.create(type=mime_type, name=name,
787                 content=data))
789         # 
790         # create the message if there's a message body (content)
791         #
792         if content:
793             message_id = self.db.msg.create(author=author,
794                 recipients=recipients, date=date.Date('.'), summary=summary,
795                 content=content, files=files, messageid=messageid,
796                 inreplyto=inreplyto)
798             # attach the message to the node
799             if nodeid:
800                 # add the message to the node's list
801                 messages = cl.get(nodeid, 'messages')
802                 messages.append(message_id)
803                 props['messages'] = messages
804             else:
805                 # pre-load the messages list
806                 props['messages'] = [message_id]
808                 # set the title to the subject
809                 if properties.has_key('title') and not props.has_key('title'):
810                     props['title'] = title
812         #
813         # perform the node change / create
814         #
815         try:
816             if nodeid:
817                 cl.set(nodeid, **props)
818             else:
819                 nodeid = cl.create(**props)
820         except (TypeError, IndexError, ValueError), message:
821             raise MailUsageError, '''
822 There was a problem with the message you sent:
823    %s
824 '''%message
826         # commit the changes to the DB
827         self.db.commit()
829         return nodeid
831 def extractUserFromList(userClass, users):
832     '''Given a list of users, try to extract the first non-anonymous user
833        and return that user, otherwise return None
834     '''
835     if len(users) > 1:
836         for user in users:
837             # make sure we don't match the anonymous or admin user
838             if userClass.get(user, 'username') in ('admin', 'anonymous'):
839                 continue
840             # first valid match will do
841             return user
842         # well, I guess we have no choice
843         return user[0]
844     elif users:
845         return users[0]
846     return None
848 def uidFromAddress(db, address, create=1):
849     ''' address is from the rfc822 module, and therefore is (name, addr)
851         user is created if they don't exist in the db already
852     '''
853     (realname, address) = address
855     # try a straight match of the address
856     user = extractUserFromList(db.user, db.user.stringFind(address=address))
857     if user is not None: return user
859     # try the user alternate addresses if possible
860     props = db.user.getprops()
861     if props.has_key('alternate_addresses'):
862         users = db.user.filter(None, {'alternate_addresses': address},
863             [], [])
864         user = extractUserFromList(db.user, users)
865         if user is not None: return user
867     # try to match the username to the address (for local
868     # submissions where the address is empty)
869     user = extractUserFromList(db.user, db.user.stringFind(username=address))
871     # couldn't match address or username, so create a new user
872     if create:
873         return db.user.create(username=address, address=address,
874             realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES)
875     else:
876         return 0
878 def parseContent(content, keep_citations, keep_body,
879         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
880         eol=re.compile(r'[\r\n]+'), 
881         signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
882         original_message=re.compile(r'^[>|\s]*-----Original Message-----$')):
883     ''' The message body is divided into sections by blank lines.
884         Sections where the second and all subsequent lines begin with a ">"
885         or "|" character are considered "quoting sections". The first line of
886         the first non-quoting section becomes the summary of the message. 
888         If keep_citations is true, then we keep the "quoting sections" in the
889         content.
890         If keep_body is true, we even keep the signature sections.
891     '''
892     # strip off leading carriage-returns / newlines
893     i = 0
894     for i in range(len(content)):
895         if content[i] not in '\r\n':
896             break
897     if i > 0:
898         sections = blank_line.split(content[i:])
899     else:
900         sections = blank_line.split(content)
902     # extract out the summary from the message
903     summary = ''
904     l = []
905     for section in sections:
906         #section = section.strip()
907         if not section:
908             continue
909         lines = eol.split(section)
910         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
911                 lines[1] and lines[1][0] in '>|'):
912             # see if there's a response somewhere inside this section (ie.
913             # no blank line between quoted message and response)
914             for line in lines[1:]:
915                 if line and line[0] not in '>|':
916                     break
917             else:
918                 # we keep quoted bits if specified in the config
919                 if keep_citations:
920                     l.append(section)
921                 continue
922             # keep this section - it has reponse stuff in it
923             if not summary:
924                 # and while we're at it, use the first non-quoted bit as
925                 # our summary
926                 summary = line
927             lines = lines[lines.index(line):]
928             section = '\n'.join(lines)
930         if not summary:
931             # if we don't have our summary yet use the first line of this
932             # section
933             summary = lines[0]
934         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
935             # lose any signature
936             break
937         elif original_message.match(lines[0]):
938             # ditch the stupid Outlook quoting of the entire original message
939             break
941         # and add the section to the output
942         l.append(section)
944     # Now reconstitute the message content minus the bits we don't care
945     # about.
946     if not keep_body:
947         content = '\n\n'.join(l)
949     return summary, content
951 # vim: set filetype=python ts=4 sw=4 et si