Code

Add 'safeget' method to hyperdb, including tests, and use it to simplify code
[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.138 2003-11-13 03:41:38 richard Exp $
77 """
79 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
80 import time, random, sys
81 import traceback, MimeWriter, rfc822
83 from roundup import hyperdb, date, password, rfc2822
84 from roundup.mailer import Mailer
86 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
88 class MailGWError(ValueError):
89     pass
91 class MailUsageError(ValueError):
92     pass
94 class MailUsageHelp(Exception):
95     pass
97 class MailLoop(Exception):
98     """ We've seen this message before... """
99     pass
101 class Unauthorized(Exception):
102     """ Access denied """
104 def initialiseSecurity(security):
105     ''' Create some Permissions and Roles on the security object
107         This function is directly invoked by security.Security.__init__()
108         as a part of the Security object instantiation.
109     '''
110     security.addPermission(name="Email Registration",
111         description="Anonymous may register through e-mail")
112     p = security.addPermission(name="Email Access",
113         description="User may use the email interface")
114     security.addPermissionToRole('Admin', p)
116 def getparam(str, param):
117     ''' From the rfc822 "header" string, extract "param" if it appears.
118     '''
119     if ';' not in str:
120         return None
121     str = str[str.index(';'):]
122     while str[:1] == ';':
123         str = str[1:]
124         if ';' in str:
125             # XXX Should parse quotes!
126             end = str.index(';')
127         else:
128             end = len(str)
129         f = str[:end]
130         if '=' in f:
131             i = f.index('=')
132             if f[:i].strip().lower() == param:
133                 return rfc822.unquote(f[i+1:].strip())
134     return None
136 class Message(mimetools.Message):
137     ''' subclass mimetools.Message so we can retrieve the parts of the
138         message...
139     '''
140     def getPart(self):
141         ''' Get a single part of a multipart message and return it as a new
142             Message instance.
143         '''
144         boundary = self.getparam('boundary')
145         mid, end = '--'+boundary, '--'+boundary+'--'
146         s = cStringIO.StringIO()
147         while 1:
148             line = self.fp.readline()
149             if not line:
150                 break
151             if line.strip() in (mid, end):
152                 break
153             s.write(line)
154         if not s.getvalue().strip():
155             return None
156         s.seek(0)
157         return Message(s)
159     def getheader(self, name, default=None):
160         hdr = mimetools.Message.getheader(self, name, default)
161         if hdr:
162             hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
163         return rfc2822.decode_header(hdr)
164  
165 class MailGW:
167     # Matches subjects like:
168     # Re: "[issue1234] title of issue [status=resolved]"
169     subject_re = re.compile(r'''
170         (?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*\s*   # Re:
171         (?P<quote>")?                                 # Leading "
172         (\[(?P<classname>[^\d\s]+)                    # [issue..
173            (?P<nodeid>\d+)?                           # ..1234]
174          \])?\s*
175         (?P<title>[^[]+)?                             # issue title 
176         "?                                            # Trailing "
177         (\[(?P<args>.+?)\])?                          # [prop=value]
178         ''', re.IGNORECASE|re.VERBOSE)
180     def __init__(self, instance, db, arguments={}):
181         self.instance = instance
182         self.db = db
183         self.arguments = arguments
184         self.mailer = Mailer(instance.config)
186         # should we trap exceptions (normal usage) or pass them through
187         # (for testing)
188         self.trapExceptions = 1
190     def do_pipe(self):
191         """ Read a message from standard input and pass it to the mail handler.
193             Read into an internal structure that we can seek on (in case
194             there's an error).
196             XXX: we may want to read this into a temporary file instead...
197         """
198         s = cStringIO.StringIO()
199         s.write(sys.stdin.read())
200         s.seek(0)
201         self.main(s)
202         return 0
204     def do_mailbox(self, filename):
205         """ Read a series of messages from the specified unix mailbox file and
206             pass each to the mail handler.
207         """
208         # open the spool file and lock it
209         import fcntl
210         # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
211         if hasattr(fcntl, 'LOCK_EX'):
212             FCNTL = fcntl
213         else:
214             import FCNTL
215         f = open(filename, 'r+')
216         fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
218         # handle and clear the mailbox
219         try:
220             from mailbox import UnixMailbox
221             mailbox = UnixMailbox(f, factory=Message)
222             # grab one message
223             message = mailbox.next()
224             while message:
225                 # handle this message
226                 self.handle_Message(message)
227                 message = mailbox.next()
228             # nuke the file contents
229             os.ftruncate(f.fileno(), 0)
230         except:
231             import traceback
232             traceback.print_exc()
233             return 1
234         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
235         return 0
237     def do_apop(self, server, user='', password=''):
238         ''' Do authentication POP
239         '''
240         self.do_pop(server, user, password, apop=1)
242     def do_pop(self, server, user='', password='', apop=0):
243         '''Read a series of messages from the specified POP server.
244         '''
245         import getpass, poplib, socket
246         try:
247             if not user:
248                 user = raw_input(_('User: '))
249             if not password:
250                 password = getpass.getpass()
251         except (KeyboardInterrupt, EOFError):
252             # Ctrl C or D maybe also Ctrl Z under Windows.
253             print "\nAborted by user."
254             return 1
256         # open a connection to the server and retrieve all messages
257         try:
258             server = poplib.POP3(server)
259         except socket.error, message:
260             print "POP server error:", message
261             return 1
262         if apop:
263             server.apop(user, password)
264         else:
265             server.user(user)
266             server.pass_(password)
267         numMessages = len(server.list()[1])
268         for i in range(1, numMessages+1):
269             # retr: returns 
270             # [ pop response e.g. '+OK 459 octets',
271             #   [ array of message lines ],
272             #   number of octets ]
273             lines = server.retr(i)[1]
274             s = cStringIO.StringIO('\n'.join(lines))
275             s.seek(0)
276             self.handle_Message(Message(s))
277             # delete the message
278             server.dele(i)
280         # quit the server to commit changes.
281         server.quit()
282         return 0
284     def main(self, fp):
285         ''' fp - the file from which to read the Message.
286         '''
287         return self.handle_Message(Message(fp))
289     def handle_Message(self, message):
290         """Handle an RFC822 Message
292         Handle the Message object by calling handle_message() and then cope
293         with any errors raised by handle_message.
294         This method's job is to make that call and handle any
295         errors in a sane manner. It should be replaced if you wish to
296         handle errors in a different manner.
297         """
298         # in some rare cases, a particularly stuffed-up e-mail will make
299         # its way into here... try to handle it gracefully
300         sendto = message.getaddrlist('resent-from')
301         if not sendto:
302             sendto = message.getaddrlist('from')
303         if sendto:
304             if not self.trapExceptions:
305                 return self.handle_message(message)
306             try:
307                 return self.handle_message(message)
308             except MailUsageHelp:
309                 # bounce the message back to the sender with the usage message
310                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
311                 sendto = [sendto[0][1]]
312                 m = ['']
313                 m.append('\n\nMail Gateway Help\n=================')
314                 m.append(fulldoc)
315                 self.mailer.bounce_message(message, sendto, m,
316                     subject="Mail Gateway Help")
317             except MailUsageError, value:
318                 # bounce the message back to the sender with the usage message
319                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
320                 sendto = [sendto[0][1]]
321                 m = ['']
322                 m.append(str(value))
323                 m.append('\n\nMail Gateway Help\n=================')
324                 m.append(fulldoc)
325                 self.mailer.bounce_message(message, sendto, m)
326             except Unauthorized, value:
327                 # just inform the user that he is not authorized
328                 sendto = [sendto[0][1]]
329                 m = ['']
330                 m.append(str(value))
331                 self.mailer.bounce_message(message, sendto, m)
332             except MailLoop:
333                 # XXX we should use a log file here...
334                 return
335             except:
336                 # bounce the message back to the sender with the error message
337                 # XXX we should use a log file here...
338                 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
339                 m = ['']
340                 m.append('An unexpected error occurred during the processing')
341                 m.append('of your message. The tracker administrator is being')
342                 m.append('notified.\n')
343                 m.append('----  traceback of failure  ----')
344                 s = cStringIO.StringIO()
345                 import traceback
346                 traceback.print_exc(None, s)
347                 m.append(s.getvalue())
348                 self.mailer.bounce_message(message, sendto, m)
349         else:
350             # very bad-looking message - we don't even know who sent it
351             # XXX we should use a log file here...
352             sendto = [self.instance.config.ADMIN_EMAIL]
353             m = ['Subject: badly formed message from mail gateway']
354             m.append('')
355             m.append('The mail gateway retrieved a message which has no From:')
356             m.append('line, indicating that it is corrupt. Please check your')
357             m.append('mail gateway source. Failed message is attached.')
358             m.append('')
359             self.mailer.bounce_message(message, sendto, m,
360                 subject='Badly formed message from mail gateway')
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         
380         # Encode message to unicode
381         charset = rfc2822.unaliasCharset(part.getparam("charset"))
382         if charset:
383             # Do conversion only if charset specified
384             edata = unicode(data, charset).encode('utf-8')
385             # Convert from dos eol to unix
386             edata = edata.replace('\r\n', '\n')
387         else:
388             # Leave message content as is
389             edata = data
390                 
391         return edata
393     def handle_message(self, message):
394         ''' message - a Message instance
396         Parse the message as per the module docstring.
397         '''
398         # detect loops
399         if message.getheader('x-roundup-loop', ''):
400             raise MailLoop
402         # XXX Don't enable. This doesn't work yet.
403 #  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
404         # handle delivery to addresses like:tracker+issue25@some.dom.ain
405         # use the embedded issue number as our issue
406 #        if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
407 #                self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
408 #            issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
409 #            for header in ['to', 'cc', 'bcc']:
410 #                addresses = message.getheader(header, '')
411 #            if addresses:
412 #              # FIXME, this only finds the first match in the addresses.
413 #                issue = re.search(issue_re, addresses, 'i')
414 #                if issue:
415 #                    classname = issue.group('classname')
416 #                    nodeid = issue.group('nodeid')
417 #                    break
419         # determine the sender's address
420         from_list = message.getaddrlist('resent-from')
421         if not from_list:
422             from_list = message.getaddrlist('from')
424         # handle the subject line
425         subject = message.getheader('subject', '')
427         if not subject:
428             raise MailUsageError, '''
429 Emails to Roundup trackers must include a Subject: line!
430 '''
432         if subject.strip().lower() == 'help':
433             raise MailUsageHelp
435         m = self.subject_re.match(subject)
437         # check for well-formed subject line
438         if m:
439             # get the classname
440             classname = m.group('classname')
441             if classname is None:
442                 # no classname, check if this a registration confirmation email
443                 # or fallback on the default class
444                 otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
445                 otk = otk_re.search(m.group('title'))
446                 if otk:
447                     self.db.confirm_registration(otk.group('otk'))
448                     subject = 'Your registration to %s is complete' % \
449                               self.instance.config.TRACKER_NAME
450                     sendto = [from_list[0][1]]
451                     self.mailer.standard_message(sendto, subject, '') 
452                     return
453                 elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
454                          self.instance.config.MAIL_DEFAULT_CLASS:
455                     classname = self.instance.config.MAIL_DEFAULT_CLASS
456                 else:
457                     # fail
458                     m = None
460         if not m:
461             raise MailUsageError, """
462 The message you sent to roundup did not contain a properly formed subject
463 line. The subject must contain a class name or designator to indicate the
464 'topic' of the message. For example:
465     Subject: [issue] This is a new issue
466       - this will create a new issue in the tracker with the title 'This is
467         a new issue'.
468     Subject: [issue1234] This is a followup to issue 1234
469       - this will append the message's contents to the existing issue 1234
470         in the tracker.
472 Subject was: '%s'
473 """%subject
475         # get the class
476         try:
477             cl = self.db.getclass(classname)
478         except KeyError:
479             raise MailUsageError, '''
480 The class name you identified in the subject line ("%s") does not exist in the
481 database.
483 Valid class names are: %s
484 Subject was: "%s"
485 '''%(classname, ', '.join(self.db.getclasses()), subject)
487         # get the optional nodeid
488         nodeid = m.group('nodeid')
490         # title is optional too
491         title = m.group('title')
492         if title:
493             title = title.strip()
494         else:
495             title = ''
497         # strip off the quotes that dumb emailers put around the subject, like
498         #      Re: "[issue1] bla blah"
499         if m.group('quote') and title.endswith('"'):
500             title = title[:-1]
502         # but we do need either a title or a nodeid...
503         if nodeid is None and not title:
504             raise MailUsageError, '''
505 I cannot match your message to a node in the database - you need to either
506 supply a full node identifier (with number, eg "[issue123]" or keep the
507 previous subject title intact so I can match that.
509 Subject was: "%s"
510 '''%subject
512         # If there's no nodeid, check to see if this is a followup and
513         # maybe someone's responded to the initial mail that created an
514         # entry. Try to find the matching nodes with the same title, and
515         # use the _last_ one matched (since that'll _usually_ be the most
516         # recent...)
517         if nodeid is None and m.group('refwd'):
518             l = cl.stringFind(title=title)
519             if l:
520                 nodeid = l[-1]
522         # if a nodeid was specified, make sure it's valid
523         if nodeid is not None and not cl.hasnode(nodeid):
524             raise MailUsageError, '''
525 The node specified by the designator in the subject of your message ("%s")
526 does not exist.
528 Subject was: "%s"
529 '''%(nodeid, subject)
531         # Handle the arguments specified by the email gateway command line.
532         # We do this by looping over the list of self.arguments looking for
533         # a -C to tell us what class then the -S setting string.
534         msg_props = {}
535         user_props = {}
536         file_props = {}
537         issue_props = {}
538         # so, if we have any arguments, use them
539         if self.arguments:
540             current_class = 'msg'
541             for option, propstring in self.arguments:
542                 if option in ( '-C', '--class'):
543                     current_class = propstring.strip()
544                     if current_class not in ('msg', 'file', 'user', 'issue'):
545                         raise MailUsageError, '''
546 The mail gateway is not properly set up. Please contact
547 %s and have them fix the incorrect class specified as:
548   %s
549 '''%(self.instance.config.ADMIN_EMAIL, current_class)
550                 if option in ('-S', '--set'):
551                     if current_class == 'issue' :
552                         errors, issue_props = setPropArrayFromString(self,
553                             cl, propstring.strip(), nodeid)
554                     elif current_class == 'file' :
555                         temp_cl = self.db.getclass('file')
556                         errors, file_props = setPropArrayFromString(self,
557                             temp_cl, propstring.strip())
558                     elif current_class == 'msg' :
559                         temp_cl = self.db.getclass('msg')
560                         errors, msg_props = setPropArrayFromString(self,
561                             temp_cl, propstring.strip())
562                     elif current_class == 'user' :
563                         temp_cl = self.db.getclass('user')
564                         errors, user_props = setPropArrayFromString(self,
565                             temp_cl, propstring.strip())
566                     if errors:
567                         raise MailUsageError, '''
568 The mail gateway is not properly set up. Please contact
569 %s and have them fix the incorrect properties:
570   %s
571 '''%(self.instance.config.ADMIN_EMAIL, errors)
573         #
574         # handle the users
575         #
576         # Don't create users if anonymous isn't allowed to register
577         create = 1
578         anonid = self.db.user.lookup('anonymous')
579         if not self.db.security.hasPermission('Email Registration', anonid):
580             create = 0
582         # ok, now figure out who the author is - create a new user if the
583         # "create" flag is true
584         author = uidFromAddress(self.db, from_list[0], create=create)
586         # if we're not recognised, and we don't get added as a user, then we
587         # must be anonymous
588         if not author:
589             author = anonid
591         # make sure the author has permission to use the email interface
592         if not self.db.security.hasPermission('Email Access', author):
593             if author == anonid:
594                 # we're anonymous and we need to be a registered user
595                 raise Unauthorized, '''
596 You are not a registered user.
598 Unknown address: %s
599 '''%from_list[0][1]
600             else:
601                 # we're registered and we're _still_ not allowed access
602                 raise Unauthorized, 'You are not permitted to access '\
603                     'this tracker.'
605         # make sure they're allowed to edit this class of information
606         if not self.db.security.hasPermission('Edit', author, classname):
607             raise Unauthorized, 'You are not permitted to edit %s.'%classname
609         # the author may have been created - make sure the change is
610         # committed before we reopen the database
611         self.db.commit()
613         # reopen the database as the author
614         username = self.db.user.get(author, 'username')
615         self.db.close()
616         self.db = self.instance.open(username)
618         # re-get the class with the new database connection
619         cl = self.db.getclass(classname)
621         # now update the recipients list
622         recipients = []
623         tracker_email = self.instance.config.TRACKER_EMAIL.lower()
624         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
625             r = recipient[1].strip().lower()
626             if r == tracker_email or not r:
627                 continue
629             # look up the recipient - create if necessary (and we're
630             # allowed to)
631             recipient = uidFromAddress(self.db, recipient, create, **user_props)
633             # if all's well, add the recipient to the list
634             if recipient:
635                 recipients.append(recipient)
637         #
638         # handle the subject argument list
639         #
640         # figure what the properties of this Class are
641         properties = cl.getprops()
642         props = {}
643         args = m.group('args')
644         if args:
645             errors, props = setPropArrayFromString(self, cl, args, nodeid)
646             # handle any errors parsing the argument list
647             if errors:
648                 errors = '\n- '.join(errors)
649                 raise MailUsageError, '''
650 There were problems handling your subject line argument list:
651 - %s
653 Subject was: "%s"
654 '''%(errors, subject)
657         # set the issue title to the subject
658         if properties.has_key('title') and not issue_props.has_key('title'):
659             issue_props['title'] = title.strip()
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                 elif subtype == 'multipart/alternative':
725                     # Search for text/plain in message with attachment and
726                     # alternative text representation
727                     # skip over intro to first boundary
728                     part.getPart()
729                     while 1:
730                         # get the next part
731                         subpart = part.getPart()
732                         if subpart is None:
733                             break
734                         # parse it
735                         if subpart.gettype() == 'text/plain' and not content:
736                             content = self.get_part_data_decoded(subpart) 
737                 else:
738                     # try name on Content-Type
739                     name = part.getparam('name')
740                     if name:
741                         name = name.strip()
742                     if not name:
743                         disp = part.getheader('content-disposition', None)
744                         if disp:
745                             name = getparam(disp, 'filename')
746                             if name:
747                                 name = name.strip()
748                     # this is just an attachment
749                     data = self.get_part_data_decoded(part) 
750                     attachments.append((name, part.gettype(), data))
751             if content is None:
752                 raise MailUsageError, '''
753 Roundup requires the submission to be plain text. The message parser could
754 not find a text/plain part to use.
755 '''
757         elif content_type[:10] == 'multipart/':
758             # skip over the intro to the first boundary
759             message.getPart()
760             content = None
761             while 1:
762                 # get the next part
763                 part = message.getPart()
764                 if part is None:
765                     break
766                 # parse it
767                 if part.gettype() == 'text/plain' and not content:
768                     content = self.get_part_data_decoded(part) 
769             if content is None:
770                 raise MailUsageError, '''
771 Roundup requires the submission to be plain text. The message parser could
772 not find a text/plain part to use.
773 '''
775         elif content_type != 'text/plain':
776             raise MailUsageError, '''
777 Roundup requires the submission to be plain text. The message parser could
778 not find a text/plain part to use.
779 '''
781         else:
782             content = self.get_part_data_decoded(message) 
783  
784         # figure how much we should muck around with the email body
785         keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
786             'no') == 'yes'
787         keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
788             'no') == 'yes'
790         # parse the body of the message, stripping out bits as appropriate
791         summary, content = parseContent(content, keep_citations, 
792             keep_body)
793         content = content.strip()
795         # 
796         # handle the attachments
797         #
798         if properties.has_key('files'):
799             files = []
800             for (name, mime_type, data) in attachments:
801                 if not name:
802                     name = "unnamed"
803                 files.append(self.db.file.create(type=mime_type, name=name,
804                                                  content=data, **file_props))
805             # attach the files to the issue
806             if nodeid:
807                 # extend the existing files list
808                 fileprop = cl.get(nodeid, 'files')
809                 fileprop.extend(files)
810                 props['files'] = fileprop
811             else:
812                 # pre-load the files list
813                 props['files'] = files
815         # 
816         # create the message if there's a message body (content)
817         #
818         if (content and properties.has_key('messages')):
819             message_id = self.db.msg.create(author=author,
820                 recipients=recipients, date=date.Date('.'), summary=summary,
821                 content=content, files=files, messageid=messageid,
822                 inreplyto=inreplyto, **msg_props)
824             # attach the message to the node
825             if nodeid:
826                 # add the message to the node's list
827                 messages = cl.get(nodeid, 'messages')
828                 messages.append(message_id)
829                 props['messages'] = messages
830             else:
831                 # pre-load the messages list
832                 props['messages'] = [message_id]
834         #
835         # perform the node change / create
836         #
837         try:
838             # merge the command line props defined in issue_props into
839             # the props dictionary because function(**props, **issue_props)
840             # is a syntax error.
841             for prop in issue_props.keys() :
842                 if not props.has_key(prop) :
843                     props[prop] = issue_props[prop]
844             if nodeid:
845                 cl.set(nodeid, **props)
846             else:
847                 nodeid = cl.create(**props)
848         except (TypeError, IndexError, ValueError), message:
849             raise MailUsageError, '''
850 There was a problem with the message you sent:
851    %s
852 '''%message
854         # commit the changes to the DB
855         self.db.commit()
857         return nodeid
859  
860 def setPropArrayFromString(self, cl, propString, nodeid=None):
861     ''' takes string of form prop=value,value;prop2=value
862         and returns (error, prop[..])
863     '''
864     props = {}
865     errors = []
866     for prop in string.split(propString, ';'):
867         # extract the property name and value
868         try:
869             propname, value = prop.split('=')
870         except ValueError, message:
871             errors.append('not of form [arg=value,value,...;'
872                 'arg=value,value,...]')
873             return (errors, props)
874         # convert the value to a hyperdb-usable value
875         propname = propname.strip()
876         try:
877             props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
878                 propname, value)
879         except hyperdb.HyperdbValueError, message:
880             errors.append(message)
881     return errors, props
884 def extractUserFromList(userClass, users):
885     '''Given a list of users, try to extract the first non-anonymous user
886        and return that user, otherwise return None
887     '''
888     if len(users) > 1:
889         for user in users:
890             # make sure we don't match the anonymous or admin user
891             if userClass.get(user, 'username') in ('admin', 'anonymous'):
892                 continue
893             # first valid match will do
894             return user
895         # well, I guess we have no choice
896         return user[0]
897     elif users:
898         return users[0]
899     return None
902 def uidFromAddress(db, address, create=1, **user_props):
903     ''' address is from the rfc822 module, and therefore is (name, addr)
905         user is created if they don't exist in the db already
906         user_props may supply additional user information
907     '''
908     (realname, address) = address
910     # try a straight match of the address
911     user = extractUserFromList(db.user, db.user.stringFind(address=address))
912     if user is not None:
913         return user
915     # try the user alternate addresses if possible
916     props = db.user.getprops()
917     if props.has_key('alternate_addresses'):
918         users = db.user.filter(None, {'alternate_addresses': address})
919         user = extractUserFromList(db.user, users)
920         if user is not None:
921             return user
923     # try to match the username to the address (for local
924     # submissions where the address is empty)
925     user = extractUserFromList(db.user, db.user.stringFind(username=address))
927     # couldn't match address or username, so create a new user
928     if create:
929         # generate a username
930         if '@' in address:
931             username = address.split('@')[0]
932         else:
933             username = address
934         trying = username
935         n = 0
936         while 1:
937             try:
938                 # does this username exist already?
939                 db.user.lookup(trying)
940             except KeyError:
941                 break
942             n += 1
943             trying = username + str(n)
945         # create!
946         return db.user.create(username=trying, address=address,
947             realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
948             password=password.Password(password.generatePassword()),
949             **user_props)
950     else:
951         return 0
954 def parseContent(content, keep_citations, keep_body,
955         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
956         eol=re.compile(r'[\r\n]+'), 
957         signature=re.compile(r'^[>|\s]*-- ?$'),
958         original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
959     ''' The message body is divided into sections by blank lines.
960         Sections where the second and all subsequent lines begin with a ">"
961         or "|" character are considered "quoting sections". The first line of
962         the first non-quoting section becomes the summary of the message. 
964         If keep_citations is true, then we keep the "quoting sections" in the
965         content.
966         If keep_body is true, we even keep the signature sections.
967     '''
968     # strip off leading carriage-returns / newlines
969     i = 0
970     for i in range(len(content)):
971         if content[i] not in '\r\n':
972             break
973     if i > 0:
974         sections = blank_line.split(content[i:])
975     else:
976         sections = blank_line.split(content)
978     # extract out the summary from the message
979     summary = ''
980     l = []
981     for section in sections:
982         #section = section.strip()
983         if not section:
984             continue
985         lines = eol.split(section)
986         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
987                 lines[1] and lines[1][0] in '>|'):
988             # see if there's a response somewhere inside this section (ie.
989             # no blank line between quoted message and response)
990             for line in lines[1:]:
991                 if line and line[0] not in '>|':
992                     break
993             else:
994                 # we keep quoted bits if specified in the config
995                 if keep_citations:
996                     l.append(section)
997                 continue
998             # keep this section - it has reponse stuff in it
999             lines = lines[lines.index(line):]
1000             section = '\n'.join(lines)
1001             # and while we're at it, use the first non-quoted bit as
1002             # our summary
1003             summary = section
1005         if not summary:
1006             # if we don't have our summary yet use the first line of this
1007             # section
1008             summary = section
1009         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1010             # lose any signature
1011             break
1012         elif original_msg.match(lines[0]):
1013             # ditch the stupid Outlook quoting of the entire original message
1014             break
1016         # and add the section to the output
1017         l.append(section)
1019     # figure the summary - find the first sentence-ending punctuation or the
1020     # first whole line, whichever is longest
1021     sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1022     if sentence:
1023         sentence = sentence.group(1)
1024     else:
1025         sentence = ''
1026     first = eol.split(summary)[0]
1027     summary = max(sentence, first)
1029     # Now reconstitute the message content minus the bits we don't care
1030     # about.
1031     if not keep_body:
1032         content = '\n\n'.join(l)
1034     return summary, content
1036 # vim: set filetype=python ts=4 sw=4 et si