Code

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