Code

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