Code

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