Code

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