Code

75dd779948e3737eae1c6aed7f357582030ceaa9
[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.117 2003-04-23 12:09:20 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))
820         # attach the files to the issue
821         if nodeid:
822             # extend the existing files list
823             fileprop = cl.get(nodeid, 'file')
824             fileprop.extend(files)
825             props['files'] = fileprop
826         else:
827             # pre-load the files list
828             props['files'] = fileprop
831         # 
832         # create the message if there's a message body (content)
833         #
834         if content:
835             message_id = self.db.msg.create(author=author,
836                 recipients=recipients, date=date.Date('.'), summary=summary,
837                 content=content, files=files, messageid=messageid,
838                 inreplyto=inreplyto, **msg_props)
840             # attach the message to the node
841             if nodeid:
842                 # add the message to the node's list
843                 messages = cl.get(nodeid, 'messages')
844                 messages.append(message_id)
845                 props['messages'] = messages
846             else:
847                 # pre-load the messages list
848                 props['messages'] = [message_id]
850                 # set the title to the subject
851                 if properties.has_key('title') and not props.has_key('title'):
852                     props['title'] = title
854         #
855         # perform the node change / create
856         #
857         try:
858             # merge the command line props defined in issue_props into
859             # the props dictionary because function(**props, **issue_props)
860             # is a syntax error.
861             for prop in issue_props.keys() :
862                 if not props.has_key(prop) :
863                     props[prop] = issue_props[prop]
864             if nodeid:
865                 cl.set(nodeid, **props)
866             else:
867                 nodeid = cl.create(**props)
868         except (TypeError, IndexError, ValueError), message:
869             raise MailUsageError, '''
870 There was a problem with the message you sent:
871    %s
872 '''%message
874         # commit the changes to the DB
875         self.db.commit()
877         return nodeid
879  
880 def setPropArrayFromString(self, cl, propString, nodeid = None):
881     ''' takes string of form prop=value,value;prop2=value
882         and returns (error, prop[..])
883     '''
884     properties = cl.getprops()
885     props = {}
886     errors = []
887     for prop in string.split(propString, ';'):
888         # extract the property name and value
889         try:
890             propname, value = prop.split('=')
891         except ValueError, message:
892             errors.append('not of form [arg=value,value,...;'
893                 'arg=value,value,...]')
894             return (errors, props)
896         # ensure it's a valid property name
897         propname = propname.strip()
898         try:
899             proptype =  properties[propname]
900         except KeyError:
901             errors.append('refers to an invalid property: "%s"'%propname)
902             continue
904         # convert the string value to a real property value
905         if isinstance(proptype, hyperdb.String):
906             props[propname] = value.strip()
907         if isinstance(proptype, hyperdb.Password):
908             props[propname] = password.Password(value.strip())
909         elif isinstance(proptype, hyperdb.Date):
910             try:
911                 props[propname] = date.Date(value.strip()).local(self.db.getUserTimezone())
912             except ValueError, message:
913                 errors.append('contains an invalid date for %s.'%propname)
914         elif isinstance(proptype, hyperdb.Interval):
915             try:
916                 props[propname] = date.Interval(value)
917             except ValueError, message:
918                 errors.append('contains an invalid date interval for %s.'%
919                     propname)
920         elif isinstance(proptype, hyperdb.Link):
921             linkcl = self.db.classes[proptype.classname]
922             propkey = linkcl.labelprop(default_to_id=1)
923             try:
924                 props[propname] = linkcl.lookup(value)
925             except KeyError, message:
926                 errors.append('"%s" is not a value for %s.'%(value, propname))
927         elif isinstance(proptype, hyperdb.Multilink):
928             # get the linked class
929             linkcl = self.db.classes[proptype.classname]
930             propkey = linkcl.labelprop(default_to_id=1)
931             if nodeid:
932                 curvalue = cl.get(nodeid, propname)
933             else:
934                 curvalue = []
936             # handle each add/remove in turn
937             # keep an extra list for all items that are
938             # definitely in the new list (in case of e.g.
939             # <propname>=A,+B, which should replace the old
940             # list with A,B)
941             set = 0
942             newvalue = []
943             for item in value.split(','):
944                 item = item.strip()
946                 # handle +/-
947                 remove = 0
948                 if item.startswith('-'):
949                     remove = 1
950                     item = item[1:]
951                 elif item.startswith('+'):
952                     item = item[1:]
953                 else:
954                     set = 1
956                 # look up the value
957                 try:
958                     item = linkcl.lookup(item)
959                 except KeyError, message:
960                     errors.append('"%s" is not a value for %s.'%(item,
961                         propname))
962                     continue
964                 # perform the add/remove
965                 if remove:
966                     try:
967                         curvalue.remove(item)
968                     except ValueError:
969                         errors.append('"%s" is not currently in for %s.'%(item,
970                             propname))
971                         continue
972                 else:
973                     newvalue.append(item)
974                     if item not in curvalue:
975                         curvalue.append(item)
977             # that's it, set the new Multilink property value,
978             # or overwrite it completely
979             if set:
980                 props[propname] = newvalue
981             else:
982                 props[propname] = curvalue
983         elif isinstance(proptype, hyperdb.Boolean):
984             value = value.strip()
985             props[propname] = value.lower() in ('yes', 'true', 'on', '1')
986         elif isinstance(proptype, hyperdb.Number):
987             value = value.strip()
988             props[propname] = float(value)
989     return errors, props
992 def extractUserFromList(userClass, users):
993     '''Given a list of users, try to extract the first non-anonymous user
994        and return that user, otherwise return None
995     '''
996     if len(users) > 1:
997         for user in users:
998             # make sure we don't match the anonymous or admin user
999             if userClass.get(user, 'username') in ('admin', 'anonymous'):
1000                 continue
1001             # first valid match will do
1002             return user
1003         # well, I guess we have no choice
1004         return user[0]
1005     elif users:
1006         return users[0]
1007     return None
1010 def uidFromAddress(db, address, create=1, **user_props):
1011     ''' address is from the rfc822 module, and therefore is (name, addr)
1013         user is created if they don't exist in the db already
1014         user_props may supply additional user information
1015     '''
1016     (realname, address) = address
1018     # try a straight match of the address
1019     user = extractUserFromList(db.user, db.user.stringFind(address=address))
1020     if user is not None:
1021         return user
1023     # try the user alternate addresses if possible
1024     props = db.user.getprops()
1025     if props.has_key('alternate_addresses'):
1026         users = db.user.filter(None, {'alternate_addresses': address})
1027         user = extractUserFromList(db.user, users)
1028         if user is not None:
1029             return user
1031     # try to match the username to the address (for local
1032     # submissions where the address is empty)
1033     user = extractUserFromList(db.user, db.user.stringFind(username=address))
1035     # couldn't match address or username, so create a new user
1036     if create:
1037         # generate a username
1038         if '@' in address:
1039             username = address.split('@')[0]
1040         else:
1041             username = address
1042         trying = username
1043         n = 0
1044         while 1:
1045             try:
1046                 # does this username exist already?
1047                 db.user.lookup(trying)
1048             except KeyError:
1049                 break
1050             n += 1
1051             trying = username + str(n)
1053         # create!
1054         return db.user.create(username=trying, address=address,
1055             realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1056             password=password.Password(password.generatePassword()),
1057             **user_props)
1058     else:
1059         return 0
1062 def parseContent(content, keep_citations, keep_body,
1063         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
1064         eol=re.compile(r'[\r\n]+'), 
1065         signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
1066         original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
1067     ''' The message body is divided into sections by blank lines.
1068         Sections where the second and all subsequent lines begin with a ">"
1069         or "|" character are considered "quoting sections". The first line of
1070         the first non-quoting section becomes the summary of the message. 
1072         If keep_citations is true, then we keep the "quoting sections" in the
1073         content.
1074         If keep_body is true, we even keep the signature sections.
1075     '''
1076     # strip off leading carriage-returns / newlines
1077     i = 0
1078     for i in range(len(content)):
1079         if content[i] not in '\r\n':
1080             break
1081     if i > 0:
1082         sections = blank_line.split(content[i:])
1083     else:
1084         sections = blank_line.split(content)
1086     # extract out the summary from the message
1087     summary = ''
1088     l = []
1089     for section in sections:
1090         #section = section.strip()
1091         if not section:
1092             continue
1093         lines = eol.split(section)
1094         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1095                 lines[1] and lines[1][0] in '>|'):
1096             # see if there's a response somewhere inside this section (ie.
1097             # no blank line between quoted message and response)
1098             for line in lines[1:]:
1099                 if line and line[0] not in '>|':
1100                     break
1101             else:
1102                 # we keep quoted bits if specified in the config
1103                 if keep_citations:
1104                     l.append(section)
1105                 continue
1106             # keep this section - it has reponse stuff in it
1107             lines = lines[lines.index(line):]
1108             section = '\n'.join(lines)
1109             # and while we're at it, use the first non-quoted bit as
1110             # our summary
1111             summary = section
1113         if not summary:
1114             # if we don't have our summary yet use the first line of this
1115             # section
1116             summary = section
1117         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1118             # lose any signature
1119             break
1120         elif original_msg.match(lines[0]):
1121             # ditch the stupid Outlook quoting of the entire original message
1122             break
1124         # and add the section to the output
1125         l.append(section)
1127     # figure the summary - find the first sentence-ending punctuation or the
1128     # first whole line, whichever is longest
1129     sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1130     if sentence:
1131         sentence = sentence.group(1)
1132     else:
1133         sentence = ''
1134     first = eol.split(summary)[0]
1135     summary = max(sentence, first)
1137     # Now reconstitute the message content minus the bits we don't care
1138     # about.
1139     if not keep_body:
1140         content = '\n\n'.join(l)
1142     return summary, content
1144 # vim: set filetype=python ts=4 sw=4 et si