Code

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