Code

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