Code

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