Code

mailgw now accepts registration confirmation mail that contains the following in
[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.128 2003-09-06 10:21:18 jlgijsbers 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, check if this a registration confirmation email
518                 # or fallback on the default class
519                 otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
520                 otk = otk_re.search(m.group('title')).group('otk')
521                 if otk:
522                     self.db.confirm_registration(otk)
523                     return
524                 elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
525                          self.instance.config.MAIL_DEFAULT_CLASS:
526                     classname = self.instance.config.MAIL_DEFAULT_CLASS
527                 else:
528                     # fail
529                     m = None
531         if not m:
532             raise MailUsageError, """
533 The message you sent to roundup did not contain a properly formed subject
534 line. The subject must contain a class name or designator to indicate the
535 'topic' of the message. For example:
536     Subject: [issue] This is a new issue
537       - this will create a new issue in the tracker with the title 'This is
538         a new issue'.
539     Subject: [issue1234] This is a followup to issue 1234
540       - this will append the message's contents to the existing issue 1234
541         in the tracker.
543 Subject was: '%s'
544 """%subject
546         # get the class
547         try:
548             cl = self.db.getclass(classname)
549         except KeyError:
550             raise MailUsageError, '''
551 The class name you identified in the subject line ("%s") does not exist in the
552 database.
554 Valid class names are: %s
555 Subject was: "%s"
556 '''%(classname, ', '.join(self.db.getclasses()), subject)
558         # get the optional nodeid
559         nodeid = m.group('nodeid')
561         # title is optional too
562         title = m.group('title')
563         if title:
564             title = title.strip()
565         else:
566             title = ''
568         # strip off the quotes that dumb emailers put around the subject, like
569         #      Re: "[issue1] bla blah"
570         if m.group('quote') and title.endswith('"'):
571             title = title[:-1]
573         # but we do need either a title or a nodeid...
574         if nodeid is None and not title:
575             raise MailUsageError, '''
576 I cannot match your message to a node in the database - you need to either
577 supply a full node identifier (with number, eg "[issue123]" or keep the
578 previous subject title intact so I can match that.
580 Subject was: "%s"
581 '''%subject
583         # If there's no nodeid, check to see if this is a followup and
584         # maybe someone's responded to the initial mail that created an
585         # entry. Try to find the matching nodes with the same title, and
586         # use the _last_ one matched (since that'll _usually_ be the most
587         # recent...)
588         if nodeid is None and m.group('refwd'):
589             l = cl.stringFind(title=title)
590             if l:
591                 nodeid = l[-1]
593         # if a nodeid was specified, make sure it's valid
594         if nodeid is not None and not cl.hasnode(nodeid):
595             raise MailUsageError, '''
596 The node specified by the designator in the subject of your message ("%s")
597 does not exist.
599 Subject was: "%s"
600 '''%(nodeid, subject)
602         # Handle the arguments specified by the email gateway command line.
603         # We do this by looping over the list of self.arguments looking for
604         # a -C to tell us what class then the -S setting string.
605         msg_props = {}
606         user_props = {}
607         file_props = {}
608         issue_props = {}
609         # so, if we have any arguments, use them
610         if self.arguments:
611             current_class = 'msg'
612             for option, propstring in self.arguments:
613                 if option in ( '-C', '--class'):
614                     current_class = propstring.strip()
615                     if current_class not in ('msg', 'file', 'user', 'issue'):
616                         raise MailUsageError, '''
617 The mail gateway is not properly set up. Please contact
618 %s and have them fix the incorrect class specified as:
619   %s
620 '''%(self.instance.config.ADMIN_EMAIL, current_class)
621                 if option in ('-S', '--set'):
622                     if current_class == 'issue' :
623                         errors, issue_props = setPropArrayFromString(self,
624                             cl, propstring.strip(), nodeid)
625                     elif current_class == 'file' :
626                         temp_cl = self.db.getclass('file')
627                         errors, file_props = setPropArrayFromString(self,
628                             temp_cl, propstring.strip())
629                     elif current_class == 'msg' :
630                         temp_cl = self.db.getclass('msg')
631                         errors, msg_props = setPropArrayFromString(self,
632                             temp_cl, propstring.strip())
633                     elif current_class == 'user' :
634                         temp_cl = self.db.getclass('user')
635                         errors, user_props = setPropArrayFromString(self,
636                             temp_cl, propstring.strip())
637                     if errors:
638                         raise MailUsageError, '''
639 The mail gateway is not properly set up. Please contact
640 %s and have them fix the incorrect properties:
641   %s
642 '''%(self.instance.config.ADMIN_EMAIL, errors)
644         #
645         # handle the users
646         #
647         # Don't create users if anonymous isn't allowed to register
648         create = 1
649         anonid = self.db.user.lookup('anonymous')
650         if not self.db.security.hasPermission('Email Registration', anonid):
651             create = 0
653         # ok, now figure out who the author is - create a new user if the
654         # "create" flag is true
655         author = uidFromAddress(self.db, message.getaddrlist('from')[0],
656             create=create)
658         # if we're not recognised, and we don't get added as a user, then we
659         # must be anonymous
660         if not author:
661             author = anonid
663         # make sure the author has permission to use the email interface
664         if not self.db.security.hasPermission('Email Access', author):
665             if author == anonid:
666                 # we're anonymous and we need to be a registered user
667                 raise Unauthorized, '''
668 You are not a registered user.
670 Unknown address: %s
671 '''%message.getaddrlist('from')[0][1]
672             else:
673                 # we're registered and we're _still_ not allowed access
674                 raise Unauthorized, 'You are not permitted to access '\
675                     'this tracker.'
677         # make sure they're allowed to edit this class of information
678         if not self.db.security.hasPermission('Edit', author, classname):
679             raise Unauthorized, 'You are not permitted to edit %s.'%classname
681         # the author may have been created - make sure the change is
682         # committed before we reopen the database
683         self.db.commit()
685         # reopen the database as the author
686         username = self.db.user.get(author, 'username')
687         self.db.close()
688         self.db = self.instance.open(username)
690         # re-get the class with the new database connection
691         cl = self.db.getclass(classname)
693         # now update the recipients list
694         recipients = []
695         tracker_email = self.instance.config.TRACKER_EMAIL.lower()
696         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
697             r = recipient[1].strip().lower()
698             if r == tracker_email or not r:
699                 continue
701             # look up the recipient - create if necessary (and we're
702             # allowed to)
703             recipient = uidFromAddress(self.db, recipient, create, **user_props)
705             # if all's well, add the recipient to the list
706             if recipient:
707                 recipients.append(recipient)
709         #
710         # handle the subject argument list
711         #
712         # figure what the properties of this Class are
713         properties = cl.getprops()
714         props = {}
715         args = m.group('args')
716         if args:
717             errors, props = setPropArrayFromString(self, cl, args, nodeid)
718             # handle any errors parsing the argument list
719             if errors:
720                 errors = '\n- '.join(errors)
721                 raise MailUsageError, '''
722 There were problems handling your subject line argument list:
723 - %s
725 Subject was: "%s"
726 '''%(errors, subject)
729         # set the issue title to the subject
730         if properties.has_key('title') and not issue_props.has_key('title'):
731             issue_props['title'] = title.strip()
733         #
734         # handle message-id and in-reply-to
735         #
736         messageid = message.getheader('message-id')
737         inreplyto = message.getheader('in-reply-to') or ''
738         # generate a messageid if there isn't one
739         if not messageid:
740             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
741                 classname, nodeid, self.instance.config.MAIL_DOMAIN)
743         #
744         # now handle the body - find the message
745         #
746         content_type =  message.gettype()
747         attachments = []
748         # General multipart handling:
749         #   Take the first text/plain part, anything else is considered an 
750         #   attachment.
751         # multipart/mixed: multiple "unrelated" parts.
752         # multipart/signed (rfc 1847): 
753         #   The control information is carried in the second of the two 
754         #   required body parts.
755         #   ACTION: Default, so if content is text/plain we get it.
756         # multipart/encrypted (rfc 1847): 
757         #   The control information is carried in the first of the two 
758         #   required body parts.
759         #   ACTION: Not handleable as the content is encrypted.
760         # multipart/related (rfc 1872, 2112, 2387):
761         #   The Multipart/Related content-type addresses the MIME
762         #   representation of compound objects.
763         #   ACTION: Default. If we are lucky there is a text/plain.
764         #   TODO: One should use the start part and look for an Alternative
765         #   that is text/plain.
766         # multipart/Alternative (rfc 1872, 1892):
767         #   only in "related" ?
768         # multipart/report (rfc 1892):
769         #   e.g. mail system delivery status reports.
770         #   ACTION: Default. Could be ignored or used for Delivery Notification 
771         #   flagging.
772         # multipart/form-data:
773         #   For web forms only.
774         if content_type == 'multipart/mixed':
775             # skip over the intro to the first boundary
776             part = message.getPart()
777             content = None
778             while 1:
779                 # get the next part
780                 part = message.getPart()
781                 if part is None:
782                     break
783                 # parse it
784                 subtype = part.gettype()
785                 if subtype == 'text/plain' and not content:
786                     # The first text/plain part is the message content.
787                     content = self.get_part_data_decoded(part) 
788                 elif subtype == 'message/rfc822':
789                     # handle message/rfc822 specially - the name should be
790                     # the subject of the actual e-mail embedded here
791                     i = part.fp.tell()
792                     mailmess = Message(part.fp)
793                     name = mailmess.getheader('subject')
794                     part.fp.seek(i)
795                     attachments.append((name, 'message/rfc822', part.fp.read()))
796                 elif subtype == 'multipart/alternative':
797                     # Search for text/plain in message with attachment and
798                     # alternative text representation
799                     # skip over intro to first boundary
800                     part.getPart()
801                     while 1:
802                         # get the next part
803                         subpart = part.getPart()
804                         if subpart is None:
805                             break
806                         # parse it
807                         if subpart.gettype() == 'text/plain' and not content:
808                             content = self.get_part_data_decoded(subpart) 
809                 else:
810                     # try name on Content-Type
811                     name = part.getparam('name')
812                     if name:
813                         name = name.strip()
814                     if not name:
815                         disp = part.getheader('content-disposition', None)
816                         if disp:
817                             name = getparam(disp, 'filename')
818                             if name:
819                                 name = name.strip()
820                     # this is just an attachment
821                     data = self.get_part_data_decoded(part) 
822                     attachments.append((name, part.gettype(), data))
823             if content is None:
824                 raise MailUsageError, '''
825 Roundup requires the submission to be plain text. The message parser could
826 not find a text/plain part to use.
827 '''
829         elif content_type[:10] == 'multipart/':
830             # skip over the intro to the first boundary
831             message.getPart()
832             content = None
833             while 1:
834                 # get the next part
835                 part = message.getPart()
836                 if part is None:
837                     break
838                 # parse it
839                 if part.gettype() == 'text/plain' and not content:
840                     content = self.get_part_data_decoded(part) 
841             if content is None:
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         elif content_type != 'text/plain':
848             raise MailUsageError, '''
849 Roundup requires the submission to be plain text. The message parser could
850 not find a text/plain part to use.
851 '''
853         else:
854             content = self.get_part_data_decoded(message) 
855  
856         # figure how much we should muck around with the email body
857         keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
858             'no') == 'yes'
859         keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
860             'no') == 'yes'
862         # parse the body of the message, stripping out bits as appropriate
863         summary, content = parseContent(content, keep_citations, 
864             keep_body)
865         content = content.strip()
867         # 
868         # handle the attachments
869         #
870         files = []
871         for (name, mime_type, data) in attachments:
872             if not name:
873                 name = "unnamed"
874             files.append(self.db.file.create(type=mime_type, name=name,
875                 content=data, **file_props))
876         # attach the files to the issue
877         if nodeid:
878             # extend the existing files list
879             fileprop = cl.get(nodeid, 'files')
880             fileprop.extend(files)
881             props['files'] = fileprop
882         else:
883             # pre-load the files list
884             props['files'] = files
887         # 
888         # create the message if there's a message body (content)
889         #
890         if content:
891             message_id = self.db.msg.create(author=author,
892                 recipients=recipients, date=date.Date('.'), summary=summary,
893                 content=content, files=files, messageid=messageid,
894                 inreplyto=inreplyto, **msg_props)
896             # attach the message to the node
897             if nodeid:
898                 # add the message to the node's list
899                 messages = cl.get(nodeid, 'messages')
900                 messages.append(message_id)
901                 props['messages'] = messages
902             else:
903                 # pre-load the messages list
904                 props['messages'] = [message_id]
906         #
907         # perform the node change / create
908         #
909         try:
910             # merge the command line props defined in issue_props into
911             # the props dictionary because function(**props, **issue_props)
912             # is a syntax error.
913             for prop in issue_props.keys() :
914                 if not props.has_key(prop) :
915                     props[prop] = issue_props[prop]
916             if nodeid:
917                 cl.set(nodeid, **props)
918             else:
919                 nodeid = cl.create(**props)
920         except (TypeError, IndexError, ValueError), message:
921             raise MailUsageError, '''
922 There was a problem with the message you sent:
923    %s
924 '''%message
926         # commit the changes to the DB
927         self.db.commit()
929         return nodeid
931  
932 def setPropArrayFromString(self, cl, propString, nodeid = None):
933     ''' takes string of form prop=value,value;prop2=value
934         and returns (error, prop[..])
935     '''
936     properties = cl.getprops()
937     props = {}
938     errors = []
939     for prop in string.split(propString, ';'):
940         # extract the property name and value
941         try:
942             propname, value = prop.split('=')
943         except ValueError, message:
944             errors.append('not of form [arg=value,value,...;'
945                 'arg=value,value,...]')
946             return (errors, props)
948         # ensure it's a valid property name
949         propname = propname.strip()
950         try:
951             proptype =  properties[propname]
952         except KeyError:
953             errors.append('refers to an invalid property: "%s"'%propname)
954             continue
956         # convert the string value to a real property value
957         if isinstance(proptype, hyperdb.String):
958             props[propname] = value.strip()
959         if isinstance(proptype, hyperdb.Password):
960             props[propname] = password.Password(value.strip())
961         elif isinstance(proptype, hyperdb.Date):
962             try:
963                 props[propname] = date.Date(value.strip()).local(self.db.getUserTimezone())
964             except ValueError, message:
965                 errors.append('contains an invalid date for %s.'%propname)
966         elif isinstance(proptype, hyperdb.Interval):
967             try:
968                 props[propname] = date.Interval(value)
969             except ValueError, message:
970                 errors.append('contains an invalid date interval for %s.'%
971                     propname)
972         elif isinstance(proptype, hyperdb.Link):
973             linkcl = self.db.classes[proptype.classname]
974             propkey = linkcl.labelprop(default_to_id=1)
975             try:
976                 props[propname] = linkcl.lookup(value)
977             except KeyError, message:
978                 errors.append('"%s" is not a value for %s.'%(value, propname))
979         elif isinstance(proptype, hyperdb.Multilink):
980             # get the linked class
981             linkcl = self.db.classes[proptype.classname]
982             propkey = linkcl.labelprop(default_to_id=1)
983             if nodeid:
984                 curvalue = cl.get(nodeid, propname)
985             else:
986                 curvalue = []
988             # handle each add/remove in turn
989             # keep an extra list for all items that are
990             # definitely in the new list (in case of e.g.
991             # <propname>=A,+B, which should replace the old
992             # list with A,B)
993             set = 0
994             newvalue = []
995             for item in value.split(','):
996                 item = item.strip()
998                 # handle +/-
999                 remove = 0
1000                 if item.startswith('-'):
1001                     remove = 1
1002                     item = item[1:]
1003                 elif item.startswith('+'):
1004                     item = item[1:]
1005                 else:
1006                     set = 1
1008                 # look up the value
1009                 try:
1010                     item = linkcl.lookup(item)
1011                 except KeyError, message:
1012                     errors.append('"%s" is not a value for %s.'%(item,
1013                         propname))
1014                     continue
1016                 # perform the add/remove
1017                 if remove:
1018                     try:
1019                         curvalue.remove(item)
1020                     except ValueError:
1021                         errors.append('"%s" is not currently in for %s.'%(item,
1022                             propname))
1023                         continue
1024                 else:
1025                     newvalue.append(item)
1026                     if item not in curvalue:
1027                         curvalue.append(item)
1029             # that's it, set the new Multilink property value,
1030             # or overwrite it completely
1031             if set:
1032                 props[propname] = newvalue
1033             else:
1034                 props[propname] = curvalue
1035         elif isinstance(proptype, hyperdb.Boolean):
1036             value = value.strip()
1037             props[propname] = value.lower() in ('yes', 'true', 'on', '1')
1038         elif isinstance(proptype, hyperdb.Number):
1039             value = value.strip()
1040             props[propname] = float(value)
1041     return errors, props
1044 def extractUserFromList(userClass, users):
1045     '''Given a list of users, try to extract the first non-anonymous user
1046        and return that user, otherwise return None
1047     '''
1048     if len(users) > 1:
1049         for user in users:
1050             # make sure we don't match the anonymous or admin user
1051             if userClass.get(user, 'username') in ('admin', 'anonymous'):
1052                 continue
1053             # first valid match will do
1054             return user
1055         # well, I guess we have no choice
1056         return user[0]
1057     elif users:
1058         return users[0]
1059     return None
1062 def uidFromAddress(db, address, create=1, **user_props):
1063     ''' address is from the rfc822 module, and therefore is (name, addr)
1065         user is created if they don't exist in the db already
1066         user_props may supply additional user information
1067     '''
1068     (realname, address) = address
1070     # try a straight match of the address
1071     user = extractUserFromList(db.user, db.user.stringFind(address=address))
1072     if user is not None:
1073         return user
1075     # try the user alternate addresses if possible
1076     props = db.user.getprops()
1077     if props.has_key('alternate_addresses'):
1078         users = db.user.filter(None, {'alternate_addresses': address})
1079         user = extractUserFromList(db.user, users)
1080         if user is not None:
1081             return user
1083     # try to match the username to the address (for local
1084     # submissions where the address is empty)
1085     user = extractUserFromList(db.user, db.user.stringFind(username=address))
1087     # couldn't match address or username, so create a new user
1088     if create:
1089         # generate a username
1090         if '@' in address:
1091             username = address.split('@')[0]
1092         else:
1093             username = address
1094         trying = username
1095         n = 0
1096         while 1:
1097             try:
1098                 # does this username exist already?
1099                 db.user.lookup(trying)
1100             except KeyError:
1101                 break
1102             n += 1
1103             trying = username + str(n)
1105         # create!
1106         return db.user.create(username=trying, address=address,
1107             realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1108             password=password.Password(password.generatePassword()),
1109             **user_props)
1110     else:
1111         return 0
1114 def parseContent(content, keep_citations, keep_body,
1115         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
1116         eol=re.compile(r'[\r\n]+'), 
1117         signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
1118         original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
1119     ''' The message body is divided into sections by blank lines.
1120         Sections where the second and all subsequent lines begin with a ">"
1121         or "|" character are considered "quoting sections". The first line of
1122         the first non-quoting section becomes the summary of the message. 
1124         If keep_citations is true, then we keep the "quoting sections" in the
1125         content.
1126         If keep_body is true, we even keep the signature sections.
1127     '''
1128     # strip off leading carriage-returns / newlines
1129     i = 0
1130     for i in range(len(content)):
1131         if content[i] not in '\r\n':
1132             break
1133     if i > 0:
1134         sections = blank_line.split(content[i:])
1135     else:
1136         sections = blank_line.split(content)
1138     # extract out the summary from the message
1139     summary = ''
1140     l = []
1141     for section in sections:
1142         #section = section.strip()
1143         if not section:
1144             continue
1145         lines = eol.split(section)
1146         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1147                 lines[1] and lines[1][0] in '>|'):
1148             # see if there's a response somewhere inside this section (ie.
1149             # no blank line between quoted message and response)
1150             for line in lines[1:]:
1151                 if line and line[0] not in '>|':
1152                     break
1153             else:
1154                 # we keep quoted bits if specified in the config
1155                 if keep_citations:
1156                     l.append(section)
1157                 continue
1158             # keep this section - it has reponse stuff in it
1159             lines = lines[lines.index(line):]
1160             section = '\n'.join(lines)
1161             # and while we're at it, use the first non-quoted bit as
1162             # our summary
1163             summary = section
1165         if not summary:
1166             # if we don't have our summary yet use the first line of this
1167             # section
1168             summary = section
1169         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1170             # lose any signature
1171             break
1172         elif original_msg.match(lines[0]):
1173             # ditch the stupid Outlook quoting of the entire original message
1174             break
1176         # and add the section to the output
1177         l.append(section)
1179     # figure the summary - find the first sentence-ending punctuation or the
1180     # first whole line, whichever is longest
1181     sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1182     if sentence:
1183         sentence = sentence.group(1)
1184     else:
1185         sentence = ''
1186     first = eol.split(summary)[0]
1187     summary = max(sentence, first)
1189     # Now reconstitute the message content minus the bits we don't care
1190     # about.
1191     if not keep_body:
1192         content = '\n\n'.join(l)
1194     return summary, content
1196 # vim: set filetype=python ts=4 sw=4 et si