Code

Added the ability to toggle where error messages go.
[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 """An e-mail gateway for Roundup.
21 Incoming messages are examined for multiple parts:
22  . In a multipart/mixed message or part, each subpart is extracted and
23    examined. The text/plain subparts are assembled to form the textual
24    body of the message, to be stored in the file associated with a "msg"
25    class node. Any parts of other types are each stored in separate files
26    and given "file" class nodes that are linked to the "msg" node. 
27  . In a multipart/alternative message or part, we look for a text/plain
28    subpart and ignore the other parts.
30 Summary
31 -------
32 The "summary" property on message nodes is taken from the first non-quoting
33 section in the message body. The message body is divided into sections by
34 blank lines. Sections where the second and all subsequent lines begin with
35 a ">" or "|" character are considered "quoting sections". The first line of
36 the first non-quoting section becomes the summary of the message. 
38 Addresses
39 ---------
40 All of the addresses in the To: and Cc: headers of the incoming message are
41 looked up among the user nodes, and the corresponding users are placed in
42 the "recipients" property on the new "msg" node. The address in the From:
43 header similarly determines the "author" property of the new "msg"
44 node. The default handling for addresses that don't have corresponding
45 users is to create new users with no passwords and a username equal to the
46 address. (The web interface does not permit logins for users with no
47 passwords.) If we prefer to reject mail from outside sources, we can simply
48 register an auditor on the "user" class that prevents the creation of user
49 nodes with no passwords. 
51 Actions
52 -------
53 The subject line of the incoming message is examined to determine whether
54 the message is an attempt to create a new item or to discuss an existing
55 item. A designator enclosed in square brackets is sought as the first thing
56 on the subject line (after skipping any "Fwd:" or "Re:" prefixes). 
58 If an item designator (class name and id number) is found there, the newly
59 created "msg" node is added to the "messages" property for that item, and
60 any new "file" nodes are added to the "files" property for the item. 
62 If just an item class name is found there, we attempt to create a new item
63 of that class with its "messages" property initialized to contain the new
64 "msg" node and its "files" property initialized to contain any new "file"
65 nodes. 
67 Triggers
68 --------
69 Both cases may trigger detectors (in the first case we are calling the
70 set() method to add the message to the item's spool; in the second case we
71 are calling the create() method to create a new node). If an auditor raises
72 an exception, the original message is bounced back to the sender with the
73 explanatory message given in the exception. 
75 $Id: mailgw.py,v 1.144 2004-03-25 19:27:15 eparker Exp $
76 """
77 __docformat__ = 'restructuredtext'
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
84 from roundup.mailer import Mailer
86 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
88 class MailGWError(ValueError):
89     pass
91 class MailUsageError(ValueError):
92     pass
94 class MailUsageHelp(Exception):
95     """ We need to send the help message to the user. """
96     pass
98 class Unauthorized(Exception):
99     """ Access denied """
100     pass
102 class IgnoreMessage(Exception):
103     """ A general class of message that we should ignore. """
104     pass
105 class IgnoreBulk(IgnoreMessage):
106         """ This is email from a mailing list or from a vacation program. """
107         pass
108 class IgnoreLoop(IgnoreMessage):
109         """ We've seen this message before... """
110         pass
112 def initialiseSecurity(security):
113     ''' Create some Permissions and Roles on the security object
115         This function is directly invoked by security.Security.__init__()
116         as a part of the Security object instantiation.
117     '''
118     security.addPermission(name="Email Registration",
119         description="Anonymous may register through e-mail")
120     p = security.addPermission(name="Email Access",
121         description="User may use the email interface")
122     security.addPermissionToRole('Admin', p)
124 def getparam(str, param):
125     ''' From the rfc822 "header" string, extract "param" if it appears.
126     '''
127     if ';' not in str:
128         return None
129     str = str[str.index(';'):]
130     while str[:1] == ';':
131         str = str[1:]
132         if ';' in str:
133             # XXX Should parse quotes!
134             end = str.index(';')
135         else:
136             end = len(str)
137         f = str[:end]
138         if '=' in f:
139             i = f.index('=')
140             if f[:i].strip().lower() == param:
141                 return rfc822.unquote(f[i+1:].strip())
142     return None
144 class Message(mimetools.Message):
145     ''' subclass mimetools.Message so we can retrieve the parts of the
146         message...
147     '''
148     def getpart(self):
149         ''' Get a single part of a multipart message and return it as a new
150             Message instance.
151         '''
152         boundary = self.getparam('boundary')
153         mid, end = '--'+boundary, '--'+boundary+'--'
154         s = cStringIO.StringIO()
155         while 1:
156             line = self.fp.readline()
157             if not line:
158                 break
159             if line.strip() in (mid, end):
160                 break
161             s.write(line)
162         if not s.getvalue().strip():
163             return None
164         s.seek(0)
165         return Message(s)
167     def getparts(self):
168         """Get all parts of this multipart message."""
169         # skip over the intro to the first boundary
170         self.getpart()
172         # accumulate the other parts
173         parts = []
174         while 1:
175             part = self.getpart()
176             if part is None:
177                 break
178             parts.append(part)
179         return parts
181     def getheader(self, name, default=None):
182         hdr = mimetools.Message.getheader(self, name, default)
183         if hdr:
184             hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
185         return rfc2822.decode_header(hdr)
187     def getname(self):
188         """Find an appropriate name for this message."""
189         if self.gettype() == 'message/rfc822':
190             # handle message/rfc822 specially - the name should be
191             # the subject of the actual e-mail embedded here
192             self.fp.seek(0)
193             name = Message(self.fp).getheader('subject')
194         else:
195             # try name on Content-Type
196             name = self.getparam('name')
197             if not name:
198                 disp = self.getheader('content-disposition', None)
199                 if disp:
200                     name = getparam(disp, 'filename')
202         if name:
203             return name.strip()
205     def getbody(self):
206         """Get the decoded message body."""
207         self.rewindbody()
208         encoding = self.getencoding()
209         data = None
210         if encoding == 'base64':
211             # BUG: is base64 really used for text encoding or
212             # are we inserting zip files here. 
213             data = binascii.a2b_base64(self.fp.read())
214         elif encoding == 'quoted-printable':
215             # the quopri module wants to work with files
216             decoded = cStringIO.StringIO()
217             quopri.decode(self.fp, decoded)
218             data = decoded.getvalue()
219         elif encoding == 'uuencoded':
220             data = binascii.a2b_uu(self.fp.read())
221         else:
222             # take it as text
223             data = self.fp.read()
224         
225         # Encode message to unicode
226         charset = rfc2822.unaliasCharset(self.getparam("charset"))
227         if charset:
228             # Do conversion only if charset specified
229             edata = unicode(data, charset).encode('utf-8')
230             # Convert from dos eol to unix
231             edata = edata.replace('\r\n', '\n')
232         else:
233             # Leave message content as is
234             edata = data
235                 
236         return edata
238     # General multipart handling:
239     #   Take the first text/plain part, anything else is considered an 
240     #   attachment.
241     # multipart/mixed: multiple "unrelated" parts.
242     # multipart/signed (rfc 1847): 
243     #   The control information is carried in the second of the two 
244     #   required body parts.
245     #   ACTION: Default, so if content is text/plain we get it.
246     # multipart/encrypted (rfc 1847): 
247     #   The control information is carried in the first of the two 
248     #   required body parts.
249     #   ACTION: Not handleable as the content is encrypted.
250     # multipart/related (rfc 1872, 2112, 2387):
251     #   The Multipart/Related content-type addresses the MIME
252     #   representation of compound objects.
253     #   ACTION: Default. If we are lucky there is a text/plain.
254     #   TODO: One should use the start part and look for an Alternative
255     #   that is text/plain.
256     # multipart/Alternative (rfc 1872, 1892):
257     #   only in "related" ?
258     # multipart/report (rfc 1892):
259     #   e.g. mail system delivery status reports.
260     #   ACTION: Default. Could be ignored or used for Delivery Notification 
261     #   flagging.
262     # multipart/form-data:
263     #   For web forms only.
265     def extract_content(self, parent_type=None):
266         """Extract the body and the attachments recursively."""
267         content_type = self.gettype()
268         content = None
269         attachments = []
270         
271         if content_type == 'text/plain':
272             content = self.getbody()
273         elif content_type[:10] == 'multipart/':
274             for part in self.getparts():
275                 new_content, new_attach = part.extract_content(content_type)
277                 # If we haven't found a text/plain part yet, take this one,
278                 # otherwise make it an attachment.
279                 if not content:
280                     content = new_content
281                 elif new_content:
282                     attachments.append(part.as_attachment())
283                     
284                 attachments.extend(new_attach)
285         elif (parent_type == 'multipart/signed' and
286               content_type == 'application/pgp-signature'):
287             # ignore it so it won't be saved as an attachment
288             pass
289         else:
290             attachments.append(self.as_attachment())
291         return content, attachments
293     def as_attachment(self):
294         """Return this message as an attachment."""
295         return (self.getname(), self.gettype(), self.getbody())
297 class MailGW:
299     # Matches subjects like:
300     # Re: "[issue1234] title of issue [status=resolved]"
301     subject_re = re.compile(r'''
302         (?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*\s*   # Re:
303         (?P<quote>")?                                 # Leading "
304         (\[(?P<classname>[^\d\s]+)                    # [issue..
305            (?P<nodeid>\d+)?                           # ..1234]
306          \])?\s*
307         (?P<title>[^[]+)?                             # issue title 
308         "?                                            # Trailing "
309         (\[(?P<args>.+?)\])?                          # [prop=value]
310         ''', re.IGNORECASE|re.VERBOSE)
312     def __init__(self, instance, db, arguments={}):
313         self.instance = instance
314         self.db = db
315         self.arguments = arguments
316         self.mailer = Mailer(instance.config)
318         # should we trap exceptions (normal usage) or pass them through
319         # (for testing)
320         self.trapExceptions = 1
322     def do_pipe(self):
323         """ Read a message from standard input and pass it to the mail handler.
325             Read into an internal structure that we can seek on (in case
326             there's an error).
328             XXX: we may want to read this into a temporary file instead...
329         """
330         s = cStringIO.StringIO()
331         s.write(sys.stdin.read())
332         s.seek(0)
333         self.main(s)
334         return 0
336     def do_mailbox(self, filename):
337         """ Read a series of messages from the specified unix mailbox file and
338             pass each to the mail handler.
339         """
340         # open the spool file and lock it
341         import fcntl
342         # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
343         if hasattr(fcntl, 'LOCK_EX'):
344             FCNTL = fcntl
345         else:
346             import FCNTL
347         f = open(filename, 'r+')
348         fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
350         # handle and clear the mailbox
351         try:
352             from mailbox import UnixMailbox
353             mailbox = UnixMailbox(f, factory=Message)
354             # grab one message
355             message = mailbox.next()
356             while message:
357                 # handle this message
358                 self.handle_Message(message)
359                 message = mailbox.next()
360             # nuke the file contents
361             os.ftruncate(f.fileno(), 0)
362         except:
363             import traceback
364             traceback.print_exc()
365             return 1
366         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
367         return 0
369     def do_apop(self, server, user='', password=''):
370         ''' Do authentication POP
371         '''
372         self.do_pop(server, user, password, apop=1)
374     def do_pop(self, server, user='', password='', apop=0):
375         '''Read a series of messages from the specified POP server.
376         '''
377         import getpass, poplib, socket
378         try:
379             if not user:
380                 user = raw_input('User: ')
381             if not password:
382                 password = getpass.getpass()
383         except (KeyboardInterrupt, EOFError):
384             # Ctrl C or D maybe also Ctrl Z under Windows.
385             print "\nAborted by user."
386             return 1
388         # open a connection to the server and retrieve all messages
389         try:
390             server = poplib.POP3(server)
391         except socket.error, message:
392             print "POP server error:", message
393             return 1
394         if apop:
395             server.apop(user, password)
396         else:
397             server.user(user)
398             server.pass_(password)
399         numMessages = len(server.list()[1])
400         for i in range(1, numMessages+1):
401             # retr: returns 
402             # [ pop response e.g. '+OK 459 octets',
403             #   [ array of message lines ],
404             #   number of octets ]
405             lines = server.retr(i)[1]
406             s = cStringIO.StringIO('\n'.join(lines))
407             s.seek(0)
408             self.handle_Message(Message(s))
409             # delete the message
410             server.dele(i)
412         # quit the server to commit changes.
413         server.quit()
414         return 0
416     def main(self, fp):
417         ''' fp - the file from which to read the Message.
418         '''
419         return self.handle_Message(Message(fp))
421     def handle_Message(self, message):
422         """Handle an RFC822 Message
424         Handle the Message object by calling handle_message() and then cope
425         with any errors raised by handle_message.
426         This method's job is to make that call and handle any
427         errors in a sane manner. It should be replaced if you wish to
428         handle errors in a different manner.
429         """
430         # in some rare cases, a particularly stuffed-up e-mail will make
431         # its way into here... try to handle it gracefully
433         # Setting the dispatcher e-mail here, so as not to clutter things. Defaulting to ADMIN_EMAIL, if not set.
434         dispatcherEmail = getattr(self.instance.config, "DISPATCHER_EMAIL", getattr(self.instance.config, "ADMIN_EMAIL"))
435         errorMessagesTo = getattr(self.instance.config, "ERROR_MESSAGES_TO", "user")
437         sendto = message.getaddrlist('resent-from')
438         if not sendto:
439             sendto = message.getaddrlist('from')
440         if not sendto:
441             # very bad-looking message - we don't even know who sent it
442             # XXX we should use a log file here...
443             
444             # [EP] This section was originally only to admin.. Not sure if this should ever go to the user?
446             if(errorMessagesTo == "dispatcher"):
447                 sendto = [dispatcherEmail]
448             elif(errorMessagesTo == "both"):
449                 sendto = [dispatcherEmail, self.instance.config.ADMIN_EMAIL]
450             else:
451                 sendto = [self.instance.config.ADMIN_EMAIL]
453             m = ['Subject: badly formed message from mail gateway']
454             m.append('')
455             m.append('The mail gateway retrieved a message which has no From:')
456             m.append('line, indicating that it is corrupt. Please check your')
457             m.append('mail gateway source. Failed message is attached.')
458             m.append('')
459             self.mailer.bounce_message(message, sendto, m,
460                 subject='Badly formed message from mail gateway')
461             return
463         # try normal message-handling
464         if not self.trapExceptions:
465             return self.handle_message(message)
466         try:
467             return self.handle_message(message)
468         except MailUsageHelp:
469             # bounce the message back to the sender with the usage message
470             fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
471             if(errorMessagesTo == "dispatcher"):
472                 sendto = [dispatcherEmail]
473             elif(errorMessagesTo == "both"):
474                 sendto = [dispatcherEmail, sendto[0][1]]
475             else:
476                 sendto = [sendto[0][1]]
478             m = ['']
479             m.append('\n\nMail Gateway Help\n=================')
480             m.append(fulldoc)
481             self.mailer.bounce_message(message, sendto, m,
482                 subject="Mail Gateway Help")
483         except MailUsageError, value:
484             # bounce the message back to the sender with the usage message
485             fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
487             if(errorMessagesTo == "dispatcher"):
488                 sendto = [dispatcherEmail]
489             elif(errorMessagesTo == "both"):
490                 sendto = [dispatcherEmail, sendto[0][1]]
491             else:
492                 sendto = [sendto[0][1]]
493             m = ['']
494             m.append(str(value))
495             m.append('\n\nMail Gateway Help\n=================')
496             m.append(fulldoc)
497             self.mailer.bounce_message(message, sendto, m)
498         except Unauthorized, value:
499             # just inform the user that he is not authorized
500             
501             if(errorMessagesTo == "dispatcher"):
502                 sendto = [dispatcherEmail]
503             elif(errorMessagesTo == "both"):
504                 sendto = [dispatcherEmail, sendto[0][1]]
505             else:
506                 sendto = [sendto[0][1]]
507             m = ['']
508             m.append(str(value))
509             self.mailer.bounce_message(message, sendto, m)
510         except IgnoreMessage:
511             # XXX we should use a log file here...
512             # do not take any action
513             # this exception is thrown when email should be ignored
514             return
515         except:
516             # bounce the message back to the sender with the error message
517             # XXX we should use a log file here...
519             if(errorMessagesTo == "dispatcher"):
520                 sendto = [dispatcherEmail]
521             elif(errorMessagesTo == "both"):
522                 sendto = [dispatcherEmail, sendto[0][1], self.instance.config.ADMIN_EMAIL]
523             else:
524                 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
526             m = ['']
527             m.append('An unexpected error occurred during the processing')
528             m.append('of your message. The tracker administrator is being')
529             m.append('notified.\n')
530             m.append('----  traceback of failure  ----')
531             s = cStringIO.StringIO()
532             import traceback
533             traceback.print_exc(None, s)
534             m.append(s.getvalue())
535             self.mailer.bounce_message(message, sendto, m)
537     def handle_message(self, message):
538         ''' message - a Message instance
540         Parse the message as per the module docstring.
541         '''
542         # detect loops
543         if message.getheader('x-roundup-loop', ''):
544             raise IgnoreLoop
546         # detect Precedence: Bulk
547         if (message.getheader('precedence', '') == 'bulk'):
548             raise IgnoreBulk
550         # XXX Don't enable. This doesn't work yet.
551 #  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
552         # handle delivery to addresses like:tracker+issue25@some.dom.ain
553         # use the embedded issue number as our issue
554 #        if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
555 #                self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
556 #            issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
557 #            for header in ['to', 'cc', 'bcc']:
558 #                addresses = message.getheader(header, '')
559 #            if addresses:
560 #              # FIXME, this only finds the first match in the addresses.
561 #                issue = re.search(issue_re, addresses, 'i')
562 #                if issue:
563 #                    classname = issue.group('classname')
564 #                    nodeid = issue.group('nodeid')
565 #                    break
567         # determine the sender's address
568         from_list = message.getaddrlist('resent-from')
569         if not from_list:
570             from_list = message.getaddrlist('from')
572         # handle the subject line
573         subject = message.getheader('subject', '')
575         if not subject:
576             raise MailUsageError, '''
577 Emails to Roundup trackers must include a Subject: line!
578 '''
580         if subject.strip().lower() == 'help':
581             raise MailUsageHelp
583         m = self.subject_re.match(subject)
585         # check for well-formed subject line
586         if m:
587             # get the classname
588             classname = m.group('classname')
589             if classname is None:
590                 # no classname, check if this a registration confirmation email
591                 # or fallback on the default class
592                 otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
593                 otk = otk_re.search(m.group('title'))
594                 if otk:
595                     self.db.confirm_registration(otk.group('otk'))
596                     subject = 'Your registration to %s is complete' % \
597                               self.instance.config.TRACKER_NAME
598                     sendto = [from_list[0][1]]
599                     self.mailer.standard_message(sendto, subject, '') 
600                     return
601                 elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
602                          self.instance.config.MAIL_DEFAULT_CLASS:
603                     classname = self.instance.config.MAIL_DEFAULT_CLASS
604                 else:
605                     # fail
606                     m = None
608         if not m:
609             raise MailUsageError, """
610 The message you sent to roundup did not contain a properly formed subject
611 line. The subject must contain a class name or designator to indicate the
612 'topic' of the message. For example:
613     Subject: [issue] This is a new issue
614       - this will create a new issue in the tracker with the title 'This is
615         a new issue'.
616     Subject: [issue1234] This is a followup to issue 1234
617       - this will append the message's contents to the existing issue 1234
618         in the tracker.
620 Subject was: '%s'
621 """%subject
623         # get the class
624         try:
625             cl = self.db.getclass(classname)
626         except KeyError:
627             raise MailUsageError, '''
628 The class name you identified in the subject line ("%s") does not exist in the
629 database.
631 Valid class names are: %s
632 Subject was: "%s"
633 '''%(classname, ', '.join(self.db.getclasses()), subject)
635         # get the optional nodeid
636         nodeid = m.group('nodeid')
638         # title is optional too
639         title = m.group('title')
640         if title:
641             title = title.strip()
642         else:
643             title = ''
645         # strip off the quotes that dumb emailers put around the subject, like
646         #      Re: "[issue1] bla blah"
647         if m.group('quote') and title.endswith('"'):
648             title = title[:-1]
650         # but we do need either a title or a nodeid...
651         if nodeid is None and not title:
652             raise MailUsageError, '''
653 I cannot match your message to a node in the database - you need to either
654 supply a full node identifier (with number, eg "[issue123]" or keep the
655 previous subject title intact so I can match that.
657 Subject was: "%s"
658 '''%subject
660         # If there's no nodeid, check to see if this is a followup and
661         # maybe someone's responded to the initial mail that created an
662         # entry. Try to find the matching nodes with the same title, and
663         # use the _last_ one matched (since that'll _usually_ be the most
664         # recent...)
665         if nodeid is None and m.group('refwd'):
666             l = cl.stringFind(title=title)
667             if l:
668                 nodeid = l[-1]
670         # if a nodeid was specified, make sure it's valid
671         if nodeid is not None and not cl.hasnode(nodeid):
672             raise MailUsageError, '''
673 The node specified by the designator in the subject of your message ("%s")
674 does not exist.
676 Subject was: "%s"
677 '''%(nodeid, subject)
679         # Handle the arguments specified by the email gateway command line.
680         # We do this by looping over the list of self.arguments looking for
681         # a -C to tell us what class then the -S setting string.
682         msg_props = {}
683         user_props = {}
684         file_props = {}
685         issue_props = {}
686         # so, if we have any arguments, use them
687         if self.arguments:
688             current_class = 'msg'
689             for option, propstring in self.arguments:
690                 if option in ( '-C', '--class'):
691                     current_class = propstring.strip()
692                     if current_class not in ('msg', 'file', 'user', 'issue'):
693                         raise MailUsageError, '''
694 The mail gateway is not properly set up. Please contact
695 %s and have them fix the incorrect class specified as:
696   %s
697 '''%(self.instance.config.ADMIN_EMAIL, current_class)
698                 if option in ('-S', '--set'):
699                     if current_class == 'issue' :
700                         errors, issue_props = setPropArrayFromString(self,
701                             cl, propstring.strip(), nodeid)
702                     elif current_class == 'file' :
703                         temp_cl = self.db.getclass('file')
704                         errors, file_props = setPropArrayFromString(self,
705                             temp_cl, propstring.strip())
706                     elif current_class == 'msg' :
707                         temp_cl = self.db.getclass('msg')
708                         errors, msg_props = setPropArrayFromString(self,
709                             temp_cl, propstring.strip())
710                     elif current_class == 'user' :
711                         temp_cl = self.db.getclass('user')
712                         errors, user_props = setPropArrayFromString(self,
713                             temp_cl, propstring.strip())
714                     if errors:
715                         raise MailUsageError, '''
716 The mail gateway is not properly set up. Please contact
717 %s and have them fix the incorrect properties:
718   %s
719 '''%(self.instance.config.ADMIN_EMAIL, errors)
721         #
722         # handle the users
723         #
724         # Don't create users if anonymous isn't allowed to register
725         create = 1
726         anonid = self.db.user.lookup('anonymous')
727         if not self.db.security.hasPermission('Email Registration', anonid):
728             create = 0
730         # ok, now figure out who the author is - create a new user if the
731         # "create" flag is true
732         author = uidFromAddress(self.db, from_list[0], create=create)
734         # if we're not recognised, and we don't get added as a user, then we
735         # must be anonymous
736         if not author:
737             author = anonid
739         # make sure the author has permission to use the email interface
740         if not self.db.security.hasPermission('Email Access', author):
741             if author == anonid:
742                 # we're anonymous and we need to be a registered user
743                 raise Unauthorized, '''
744 You are not a registered user.
746 Unknown address: %s
747 '''%from_list[0][1]
748             else:
749                 # we're registered and we're _still_ not allowed access
750                 raise Unauthorized, 'You are not permitted to access '\
751                     'this tracker.'
753         # make sure they're allowed to edit this class of information
754         if not self.db.security.hasPermission('Edit', author, classname):
755             raise Unauthorized, 'You are not permitted to edit %s.'%classname
757         # the author may have been created - make sure the change is
758         # committed before we reopen the database
759         self.db.commit()
761         # reopen the database as the author
762         username = self.db.user.get(author, 'username')
763         self.db.close()
764         self.db = self.instance.open(username)
766         # re-get the class with the new database connection
767         cl = self.db.getclass(classname)
769         # now update the recipients list
770         recipients = []
771         tracker_email = self.instance.config.TRACKER_EMAIL.lower()
772         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
773             r = recipient[1].strip().lower()
774             if r == tracker_email or not r:
775                 continue
777             # look up the recipient - create if necessary (and we're
778             # allowed to)
779             recipient = uidFromAddress(self.db, recipient, create, **user_props)
781             # if all's well, add the recipient to the list
782             if recipient:
783                 recipients.append(recipient)
785         #
786         # handle the subject argument list
787         #
788         # figure what the properties of this Class are
789         properties = cl.getprops()
790         props = {}
791         args = m.group('args')
792         if args:
793             errors, props = setPropArrayFromString(self, cl, args, nodeid)
794             # handle any errors parsing the argument list
795             if errors:
796                 errors = '\n- '.join(map(str, errors))
797                 raise MailUsageError, '''
798 There were problems handling your subject line argument list:
799 - %s
801 Subject was: "%s"
802 '''%(errors, subject)
805         # set the issue title to the subject
806         if properties.has_key('title') and not issue_props.has_key('title'):
807             issue_props['title'] = title.strip()
809         #
810         # handle message-id and in-reply-to
811         #
812         messageid = message.getheader('message-id')
813         inreplyto = message.getheader('in-reply-to') or ''
814         # generate a messageid if there isn't one
815         if not messageid:
816             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
817                 classname, nodeid, self.instance.config.MAIL_DOMAIN)
819         # now handle the body - find the message
820         content, attachments = message.extract_content()
821         if content is None:
822             raise MailUsageError, '''
823 Roundup requires the submission to be plain text. The message parser could
824 not find a text/plain part to use.
825 '''
826  
827         # figure how much we should muck around with the email body
828         keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
829             'no') == 'yes'
830         keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
831             'no') == 'yes'
833         # parse the body of the message, stripping out bits as appropriate
834         summary, content = parseContent(content, keep_citations, 
835             keep_body)
836         content = content.strip()
838         # 
839         # handle the attachments
840         #
841         if properties.has_key('files'):
842             files = []
843             for (name, mime_type, data) in attachments:
844                 if not name:
845                     name = "unnamed"
846                 files.append(self.db.file.create(type=mime_type, name=name,
847                                                  content=data, **file_props))
848             # attach the files to the issue
849             if nodeid:
850                 # extend the existing files list
851                 fileprop = cl.get(nodeid, 'files')
852                 fileprop.extend(files)
853                 props['files'] = fileprop
854             else:
855                 # pre-load the files list
856                 props['files'] = files
858         # 
859         # create the message if there's a message body (content)
860         #
861         if (content and properties.has_key('messages')):
862             message_id = self.db.msg.create(author=author,
863                 recipients=recipients, date=date.Date('.'), summary=summary,
864                 content=content, files=files, messageid=messageid,
865                 inreplyto=inreplyto, **msg_props)
867             # attach the message to the node
868             if nodeid:
869                 # add the message to the node's list
870                 messages = cl.get(nodeid, 'messages')
871                 messages.append(message_id)
872                 props['messages'] = messages
873             else:
874                 # pre-load the messages list
875                 props['messages'] = [message_id]
877         #
878         # perform the node change / create
879         #
880         try:
881             # merge the command line props defined in issue_props into
882             # the props dictionary because function(**props, **issue_props)
883             # is a syntax error.
884             for prop in issue_props.keys() :
885                 if not props.has_key(prop) :
886                     props[prop] = issue_props[prop]
887             if nodeid:
888                 cl.set(nodeid, **props)
889             else:
890                 nodeid = cl.create(**props)
891         except (TypeError, IndexError, ValueError), message:
892             raise MailUsageError, '''
893 There was a problem with the message you sent:
894    %s
895 '''%message
897         # commit the changes to the DB
898         self.db.commit()
900         return nodeid
902  
903 def setPropArrayFromString(self, cl, propString, nodeid=None):
904     ''' takes string of form prop=value,value;prop2=value
905         and returns (error, prop[..])
906     '''
907     props = {}
908     errors = []
909     for prop in string.split(propString, ';'):
910         # extract the property name and value
911         try:
912             propname, value = prop.split('=')
913         except ValueError, message:
914             errors.append('not of form [arg=value,value,...;'
915                 'arg=value,value,...]')
916             return (errors, props)
917         # convert the value to a hyperdb-usable value
918         propname = propname.strip()
919         try:
920             props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
921                 propname, value)
922         except hyperdb.HyperdbValueError, message:
923             errors.append(message)
924     return errors, props
927 def extractUserFromList(userClass, users):
928     '''Given a list of users, try to extract the first non-anonymous user
929        and return that user, otherwise return None
930     '''
931     if len(users) > 1:
932         for user in users:
933             # make sure we don't match the anonymous or admin user
934             if userClass.get(user, 'username') in ('admin', 'anonymous'):
935                 continue
936             # first valid match will do
937             return user
938         # well, I guess we have no choice
939         return user[0]
940     elif users:
941         return users[0]
942     return None
945 def uidFromAddress(db, address, create=1, **user_props):
946     ''' address is from the rfc822 module, and therefore is (name, addr)
948         user is created if they don't exist in the db already
949         user_props may supply additional user information
950     '''
951     (realname, address) = address
953     # try a straight match of the address
954     user = extractUserFromList(db.user, db.user.stringFind(address=address))
955     if user is not None:
956         return user
958     # try the user alternate addresses if possible
959     props = db.user.getprops()
960     if props.has_key('alternate_addresses'):
961         users = db.user.filter(None, {'alternate_addresses': address})
962         user = extractUserFromList(db.user, users)
963         if user is not None:
964             return user
966     # try to match the username to the address (for local
967     # submissions where the address is empty)
968     user = extractUserFromList(db.user, db.user.stringFind(username=address))
970     # couldn't match address or username, so create a new user
971     if create:
972         # generate a username
973         if '@' in address:
974             username = address.split('@')[0]
975         else:
976             username = address
977         trying = username
978         n = 0
979         while 1:
980             try:
981                 # does this username exist already?
982                 db.user.lookup(trying)
983             except KeyError:
984                 break
985             n += 1
986             trying = username + str(n)
988         # create!
989         return db.user.create(username=trying, address=address,
990             realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
991             password=password.Password(password.generatePassword()),
992             **user_props)
993     else:
994         return 0
997 def parseContent(content, keep_citations, keep_body,
998         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
999         eol=re.compile(r'[\r\n]+'), 
1000         signature=re.compile(r'^[>|\s]*-- ?$'),
1001         original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
1002     ''' The message body is divided into sections by blank lines.
1003         Sections where the second and all subsequent lines begin with a ">"
1004         or "|" character are considered "quoting sections". The first line of
1005         the first non-quoting section becomes the summary of the message. 
1007         If keep_citations is true, then we keep the "quoting sections" in the
1008         content.
1009         If keep_body is true, we even keep the signature sections.
1010     '''
1011     # strip off leading carriage-returns / newlines
1012     i = 0
1013     for i in range(len(content)):
1014         if content[i] not in '\r\n':
1015             break
1016     if i > 0:
1017         sections = blank_line.split(content[i:])
1018     else:
1019         sections = blank_line.split(content)
1021     # extract out the summary from the message
1022     summary = ''
1023     l = []
1024     for section in sections:
1025         #section = section.strip()
1026         if not section:
1027             continue
1028         lines = eol.split(section)
1029         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1030                 lines[1] and lines[1][0] in '>|'):
1031             # see if there's a response somewhere inside this section (ie.
1032             # no blank line between quoted message and response)
1033             for line in lines[1:]:
1034                 if line and line[0] not in '>|':
1035                     break
1036             else:
1037                 # we keep quoted bits if specified in the config
1038                 if keep_citations:
1039                     l.append(section)
1040                 continue
1041             # keep this section - it has reponse stuff in it
1042             lines = lines[lines.index(line):]
1043             section = '\n'.join(lines)
1044             # and while we're at it, use the first non-quoted bit as
1045             # our summary
1046             summary = section
1048         if not summary:
1049             # if we don't have our summary yet use the first line of this
1050             # section
1051             summary = section
1052         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1053             # lose any signature
1054             break
1055         elif original_msg.match(lines[0]):
1056             # ditch the stupid Outlook quoting of the entire original message
1057             break
1059         # and add the section to the output
1060         l.append(section)
1062     # figure the summary - find the first sentence-ending punctuation or the
1063     # first whole line, whichever is longest
1064     sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1065     if sentence:
1066         sentence = sentence.group(1)
1067     else:
1068         sentence = ''
1069     first = eol.split(summary)[0]
1070     summary = max(sentence, first)
1072     # Now reconstitute the message content minus the bits we don't care
1073     # about.
1074     if not keep_body:
1075         content = '\n\n'.join(l)
1077     return summary, content
1079 # vim: set filetype=python ts=4 sw=4 et si