Code

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