Code

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