Code

added IMAP support to mail gateway (sf rfe 934000)
[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.147 2004-04-13 04:11:06 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                 #print 'Using INBOX'
411                 (typ, data) = server.select()
412             else:
413                 #print 'Using mailbox' , mailbox
414                 (typ, data) = server.select(mailbox=mailbox)
415             if typ != 'OK':
416                 print 'Failed to get mailbox "%s": %s' % (mailbox, data)
417                 return 1
418             try:
419                 numMessages = int(data[0])
420                 #print 'Found %s messages' % numMessages
421             except ValueError:
422                 print 'Invalid return value from mailbox'
423                 return 1
424             for i in range(1, numMessages+1):
425                 #print 'Processing message ', i
426                 (typ, data) = server.fetch(str(i), '(RFC822)')
427                 #This marks the message as deleted.
428                 server.store(str(i), '+FLAGS', r'(\Deleted)')
429                 #This is the raw text of the message
430                 s = cStringIO.StringIO(data[0][1])
431                 s.seek(0)
432                 self.handle_Message(Message(s))
433             server.close()
434         finally:
435             try:
436                 server.expunge()
437             except:
438                 pass
439             server.logout()
441         return 0
444     def do_apop(self, server, user='', password=''):
445         ''' Do authentication POP
446         '''
447         self.do_pop(server, user, password, apop=1)
449     def do_pop(self, server, user='', password='', apop=0):
450         '''Read a series of messages from the specified POP server.
451         '''
452         import getpass, poplib, socket
453         try:
454             if not user:
455                 user = raw_input('User: ')
456             if not password:
457                 password = getpass.getpass()
458         except (KeyboardInterrupt, EOFError):
459             # Ctrl C or D maybe also Ctrl Z under Windows.
460             print "\nAborted by user."
461             return 1
463         # open a connection to the server and retrieve all messages
464         try:
465             server = poplib.POP3(server)
466         except socket.error, message:
467             print "POP server error:", message
468             return 1
469         if apop:
470             server.apop(user, password)
471         else:
472             server.user(user)
473             server.pass_(password)
474         numMessages = len(server.list()[1])
475         for i in range(1, numMessages+1):
476             # retr: returns 
477             # [ pop response e.g. '+OK 459 octets',
478             #   [ array of message lines ],
479             #   number of octets ]
480             lines = server.retr(i)[1]
481             s = cStringIO.StringIO('\n'.join(lines))
482             s.seek(0)
483             self.handle_Message(Message(s))
484             # delete the message
485             server.dele(i)
487         # quit the server to commit changes.
488         server.quit()
489         return 0
491     def main(self, fp):
492         ''' fp - the file from which to read the Message.
493         '''
494         return self.handle_Message(Message(fp))
496     def handle_Message(self, message):
497         """Handle an RFC822 Message
499         Handle the Message object by calling handle_message() and then cope
500         with any errors raised by handle_message.
501         This method's job is to make that call and handle any
502         errors in a sane manner. It should be replaced if you wish to
503         handle errors in a different manner.
504         """
505         # in some rare cases, a particularly stuffed-up e-mail will make
506         # its way into here... try to handle it gracefully
508         sendto = message.getaddrlist('resent-from')
509         if not sendto:
510             sendto = message.getaddrlist('from')
511         if not sendto:
512             # very bad-looking message - we don't even know who sent it
513             # XXX we should use a log file here...
514             
515             sendto = [self.instance.config.ADMIN_EMAIL]
517             m = ['Subject: badly formed message from mail gateway']
518             m.append('')
519             m.append('The mail gateway retrieved a message which has no From:')
520             m.append('line, indicating that it is corrupt. Please check your')
521             m.append('mail gateway source. Failed message is attached.')
522             m.append('')
523             self.mailer.bounce_message(message, sendto, m,
524                 subject='Badly formed message from mail gateway')
525             return
527         # try normal message-handling
528         if not self.trapExceptions:
529             return self.handle_message(message)
530         try:
531             return self.handle_message(message)
532         except MailUsageHelp:
533             # bounce the message back to the sender with the usage message
534             fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
535             m = ['']
536             m.append('\n\nMail Gateway Help\n=================')
537             m.append(fulldoc)
538             self.mailer.bounce_message(message, [sendto[0][1]], m,
539                 subject="Mail Gateway Help")
540         except MailUsageError, value:
541             # bounce the message back to the sender with the usage message
542             fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
543             m = ['']
544             m.append(str(value))
545             m.append('\n\nMail Gateway Help\n=================')
546             m.append(fulldoc)
547             self.mailer.bounce_message(message, [sendto[0][1]], m)
548         except Unauthorized, value:
549             # just inform the user that he is not authorized
550             m = ['']
551             m.append(str(value))
552             self.mailer.bounce_message(message, [sendto[0][1]], m)
553         except IgnoreMessage:
554             # XXX we should use a log file here...
555             # do not take any action
556             # this exception is thrown when email should be ignored
557             return
558         except:
559             # bounce the message back to the sender with the error message
560             # XXX we should use a log file here...
561             # let the admin know that something very bad is happening
562             sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
563             m = ['']
564             m.append('An unexpected error occurred during the processing')
565             m.append('of your message. The tracker administrator is being')
566             m.append('notified.\n')
567             m.append('----  traceback of failure  ----')
568             s = cStringIO.StringIO()
569             import traceback
570             traceback.print_exc(None, s)
571             m.append(s.getvalue())
572             self.mailer.bounce_message(message, sendto, m)
574     def handle_message(self, message):
575         ''' message - a Message instance
577         Parse the message as per the module docstring.
578         '''
579         # detect loops
580         if message.getheader('x-roundup-loop', ''):
581             raise IgnoreLoop
583         # detect Precedence: Bulk
584         if (message.getheader('precedence', '') == 'bulk'):
585             raise IgnoreBulk
587         # XXX Don't enable. This doesn't work yet.
588 #  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
589         # handle delivery to addresses like:tracker+issue25@some.dom.ain
590         # use the embedded issue number as our issue
591 #        if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
592 #                self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
593 #            issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
594 #            for header in ['to', 'cc', 'bcc']:
595 #                addresses = message.getheader(header, '')
596 #            if addresses:
597 #              # FIXME, this only finds the first match in the addresses.
598 #                issue = re.search(issue_re, addresses, 'i')
599 #                if issue:
600 #                    classname = issue.group('classname')
601 #                    nodeid = issue.group('nodeid')
602 #                    break
604         # determine the sender's address
605         from_list = message.getaddrlist('resent-from')
606         if not from_list:
607             from_list = message.getaddrlist('from')
609         # handle the subject line
610         subject = message.getheader('subject', '')
612         if not subject:
613             raise MailUsageError, '''
614 Emails to Roundup trackers must include a Subject: line!
615 '''
617         if subject.strip().lower() == 'help':
618             raise MailUsageHelp
620         m = self.subject_re.match(subject)
622         # check for well-formed subject line
623         if m:
624             # get the classname
625             classname = m.group('classname')
626             if classname is None:
627                 # no classname, check if this a registration confirmation email
628                 # or fallback on the default class
629                 otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
630                 otk = otk_re.search(m.group('title'))
631                 if otk:
632                     self.db.confirm_registration(otk.group('otk'))
633                     subject = 'Your registration to %s is complete' % \
634                               self.instance.config.TRACKER_NAME
635                     sendto = [from_list[0][1]]
636                     self.mailer.standard_message(sendto, subject, '') 
637                     return
638                 elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
639                          self.instance.config.MAIL_DEFAULT_CLASS:
640                     classname = self.instance.config.MAIL_DEFAULT_CLASS
641                 else:
642                     # fail
643                     m = None
645         if not m:
646             raise MailUsageError, """
647 The message you sent to roundup did not contain a properly formed subject
648 line. The subject must contain a class name or designator to indicate the
649 'topic' of the message. For example:
650     Subject: [issue] This is a new issue
651       - this will create a new issue in the tracker with the title 'This is
652         a new issue'.
653     Subject: [issue1234] This is a followup to issue 1234
654       - this will append the message's contents to the existing issue 1234
655         in the tracker.
657 Subject was: '%s'
658 """%subject
660         # get the class
661         try:
662             cl = self.db.getclass(classname)
663         except KeyError:
664             raise MailUsageError, '''
665 The class name you identified in the subject line ("%s") does not exist in the
666 database.
668 Valid class names are: %s
669 Subject was: "%s"
670 '''%(classname, ', '.join(self.db.getclasses()), subject)
672         # get the optional nodeid
673         nodeid = m.group('nodeid')
675         # title is optional too
676         title = m.group('title')
677         if title:
678             title = title.strip()
679         else:
680             title = ''
682         # strip off the quotes that dumb emailers put around the subject, like
683         #      Re: "[issue1] bla blah"
684         if m.group('quote') and title.endswith('"'):
685             title = title[:-1]
687         # but we do need either a title or a nodeid...
688         if nodeid is None and not title:
689             raise MailUsageError, '''
690 I cannot match your message to a node in the database - you need to either
691 supply a full node identifier (with number, eg "[issue123]" or keep the
692 previous subject title intact so I can match that.
694 Subject was: "%s"
695 '''%subject
697         # If there's no nodeid, check to see if this is a followup and
698         # maybe someone's responded to the initial mail that created an
699         # entry. Try to find the matching nodes with the same title, and
700         # use the _last_ one matched (since that'll _usually_ be the most
701         # recent...)
702         if nodeid is None and m.group('refwd'):
703             l = cl.stringFind(title=title)
704             if l:
705                 nodeid = l[-1]
707         # if a nodeid was specified, make sure it's valid
708         if nodeid is not None and not cl.hasnode(nodeid):
709             raise MailUsageError, '''
710 The node specified by the designator in the subject of your message ("%s")
711 does not exist.
713 Subject was: "%s"
714 '''%(nodeid, subject)
716         # Handle the arguments specified by the email gateway command line.
717         # We do this by looping over the list of self.arguments looking for
718         # a -C to tell us what class then the -S setting string.
719         msg_props = {}
720         user_props = {}
721         file_props = {}
722         issue_props = {}
723         # so, if we have any arguments, use them
724         if self.arguments:
725             current_class = 'msg'
726             for option, propstring in self.arguments:
727                 if option in ( '-C', '--class'):
728                     current_class = propstring.strip()
729                     if current_class not in ('msg', 'file', 'user', 'issue'):
730                         raise MailUsageError, '''
731 The mail gateway is not properly set up. Please contact
732 %s and have them fix the incorrect class specified as:
733   %s
734 '''%(self.instance.config.ADMIN_EMAIL, current_class)
735                 if option in ('-S', '--set'):
736                     if current_class == 'issue' :
737                         errors, issue_props = setPropArrayFromString(self,
738                             cl, propstring.strip(), nodeid)
739                     elif current_class == 'file' :
740                         temp_cl = self.db.getclass('file')
741                         errors, file_props = setPropArrayFromString(self,
742                             temp_cl, propstring.strip())
743                     elif current_class == 'msg' :
744                         temp_cl = self.db.getclass('msg')
745                         errors, msg_props = setPropArrayFromString(self,
746                             temp_cl, propstring.strip())
747                     elif current_class == 'user' :
748                         temp_cl = self.db.getclass('user')
749                         errors, user_props = setPropArrayFromString(self,
750                             temp_cl, propstring.strip())
751                     if errors:
752                         raise MailUsageError, '''
753 The mail gateway is not properly set up. Please contact
754 %s and have them fix the incorrect properties:
755   %s
756 '''%(self.instance.config.ADMIN_EMAIL, errors)
758         #
759         # handle the users
760         #
761         # Don't create users if anonymous isn't allowed to register
762         create = 1
763         anonid = self.db.user.lookup('anonymous')
764         if not self.db.security.hasPermission('Email Registration', anonid):
765             create = 0
767         # ok, now figure out who the author is - create a new user if the
768         # "create" flag is true
769         author = uidFromAddress(self.db, from_list[0], create=create)
771         # if we're not recognised, and we don't get added as a user, then we
772         # must be anonymous
773         if not author:
774             author = anonid
776         # make sure the author has permission to use the email interface
777         if not self.db.security.hasPermission('Email Access', author):
778             if author == anonid:
779                 # we're anonymous and we need to be a registered user
780                 raise Unauthorized, '''
781 You are not a registered user.
783 Unknown address: %s
784 '''%from_list[0][1]
785             else:
786                 # we're registered and we're _still_ not allowed access
787                 raise Unauthorized, 'You are not permitted to access '\
788                     'this tracker.'
790         # make sure they're allowed to edit this class of information
791         if not self.db.security.hasPermission('Edit', author, classname):
792             raise Unauthorized, 'You are not permitted to edit %s.'%classname
794         # the author may have been created - make sure the change is
795         # committed before we reopen the database
796         self.db.commit()
798         # reopen the database as the author
799         username = self.db.user.get(author, 'username')
800         self.db.close()
801         self.db = self.instance.open(username)
803         # re-get the class with the new database connection
804         cl = self.db.getclass(classname)
806         # now update the recipients list
807         recipients = []
808         tracker_email = self.instance.config.TRACKER_EMAIL.lower()
809         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
810             r = recipient[1].strip().lower()
811             if r == tracker_email or not r:
812                 continue
814             # look up the recipient - create if necessary (and we're
815             # allowed to)
816             recipient = uidFromAddress(self.db, recipient, create, **user_props)
818             # if all's well, add the recipient to the list
819             if recipient:
820                 recipients.append(recipient)
822         #
823         # handle the subject argument list
824         #
825         # figure what the properties of this Class are
826         properties = cl.getprops()
827         props = {}
828         args = m.group('args')
829         if args:
830             errors, props = setPropArrayFromString(self, cl, args, nodeid)
831             # handle any errors parsing the argument list
832             if errors:
833                 errors = '\n- '.join(map(str, errors))
834                 raise MailUsageError, '''
835 There were problems handling your subject line argument list:
836 - %s
838 Subject was: "%s"
839 '''%(errors, subject)
842         # set the issue title to the subject
843         if properties.has_key('title') and not issue_props.has_key('title'):
844             issue_props['title'] = title.strip()
846         #
847         # handle message-id and in-reply-to
848         #
849         messageid = message.getheader('message-id')
850         inreplyto = message.getheader('in-reply-to') or ''
851         # generate a messageid if there isn't one
852         if not messageid:
853             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
854                 classname, nodeid, self.instance.config.MAIL_DOMAIN)
856         # now handle the body - find the message
857         content, attachments = message.extract_content()
858         if content is None:
859             raise MailUsageError, '''
860 Roundup requires the submission to be plain text. The message parser could
861 not find a text/plain part to use.
862 '''
863  
864         # figure how much we should muck around with the email body
865         keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
866             'no') == 'yes'
867         keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
868             'no') == 'yes'
870         # parse the body of the message, stripping out bits as appropriate
871         summary, content = parseContent(content, keep_citations, 
872             keep_body)
873         content = content.strip()
875         # 
876         # handle the attachments
877         #
878         if properties.has_key('files'):
879             files = []
880             for (name, mime_type, data) in attachments:
881                 if not name:
882                     name = "unnamed"
883                 try:
884                     fileid = self.db.file.create(type=mime_type, name=name,
885                          content=data, **file_props)
886                 except exceptions.Reject:
887                     pass
888                 else:
889                     files.append(fileid)
890             # attach the files to the issue
891             if nodeid:
892                 # extend the existing files list
893                 fileprop = cl.get(nodeid, 'files')
894                 fileprop.extend(files)
895                 props['files'] = fileprop
896             else:
897                 # pre-load the files list
898                 props['files'] = files
900         # 
901         # create the message if there's a message body (content)
902         #
903         if (content and properties.has_key('messages')):
904             try:
905                 message_id = self.db.msg.create(author=author,
906                     recipients=recipients, date=date.Date('.'),
907                     summary=summary, content=content, files=files,
908                     messageid=messageid, inreplyto=inreplyto, **msg_props)
909             except exceptions.Reject:
910                 pass
911             else:
912                 # attach the message to the node
913                 if nodeid:
914                     # add the message to the node's list
915                     messages = cl.get(nodeid, 'messages')
916                     messages.append(message_id)
917                     props['messages'] = messages
918                 else:
919                     # pre-load the messages list
920                     props['messages'] = [message_id]
922         #
923         # perform the node change / create
924         #
925         try:
926             # merge the command line props defined in issue_props into
927             # the props dictionary because function(**props, **issue_props)
928             # is a syntax error.
929             for prop in issue_props.keys() :
930                 if not props.has_key(prop) :
931                     props[prop] = issue_props[prop]
932             if nodeid:
933                 cl.set(nodeid, **props)
934             else:
935                 nodeid = cl.create(**props)
936         except (TypeError, IndexError, ValueError), message:
937             raise MailUsageError, '''
938 There was a problem with the message you sent:
939    %s
940 '''%message
942         # commit the changes to the DB
943         self.db.commit()
945         return nodeid
947  
948 def setPropArrayFromString(self, cl, propString, nodeid=None):
949     ''' takes string of form prop=value,value;prop2=value
950         and returns (error, prop[..])
951     '''
952     props = {}
953     errors = []
954     for prop in string.split(propString, ';'):
955         # extract the property name and value
956         try:
957             propname, value = prop.split('=')
958         except ValueError, message:
959             errors.append('not of form [arg=value,value,...;'
960                 'arg=value,value,...]')
961             return (errors, props)
962         # convert the value to a hyperdb-usable value
963         propname = propname.strip()
964         try:
965             props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
966                 propname, value)
967         except hyperdb.HyperdbValueError, message:
968             errors.append(message)
969     return errors, props
972 def extractUserFromList(userClass, users):
973     '''Given a list of users, try to extract the first non-anonymous user
974        and return that user, otherwise return None
975     '''
976     if len(users) > 1:
977         for user in users:
978             # make sure we don't match the anonymous or admin user
979             if userClass.get(user, 'username') in ('admin', 'anonymous'):
980                 continue
981             # first valid match will do
982             return user
983         # well, I guess we have no choice
984         return user[0]
985     elif users:
986         return users[0]
987     return None
990 def uidFromAddress(db, address, create=1, **user_props):
991     ''' address is from the rfc822 module, and therefore is (name, addr)
993         user is created if they don't exist in the db already
994         user_props may supply additional user information
995     '''
996     (realname, address) = address
998     # try a straight match of the address
999     user = extractUserFromList(db.user, db.user.stringFind(address=address))
1000     if user is not None:
1001         return user
1003     # try the user alternate addresses if possible
1004     props = db.user.getprops()
1005     if props.has_key('alternate_addresses'):
1006         users = db.user.filter(None, {'alternate_addresses': address})
1007         user = extractUserFromList(db.user, users)
1008         if user is not None:
1009             return user
1011     # try to match the username to the address (for local
1012     # submissions where the address is empty)
1013     user = extractUserFromList(db.user, db.user.stringFind(username=address))
1015     # couldn't match address or username, so create a new user
1016     if create:
1017         # generate a username
1018         if '@' in address:
1019             username = address.split('@')[0]
1020         else:
1021             username = address
1022         trying = username
1023         n = 0
1024         while 1:
1025             try:
1026                 # does this username exist already?
1027                 db.user.lookup(trying)
1028             except KeyError:
1029                 break
1030             n += 1
1031             trying = username + str(n)
1033         # create!
1034         try:
1035             return db.user.create(username=trying, address=address,
1036                 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1037                 password=password.Password(password.generatePassword()),
1038                 **user_props)
1039         except exceptions.Reject:
1040             return 0
1041     else:
1042         return 0
1045 def parseContent(content, keep_citations, keep_body,
1046         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
1047         eol=re.compile(r'[\r\n]+'), 
1048         signature=re.compile(r'^[>|\s]*-- ?$'),
1049         original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
1050     ''' The message body is divided into sections by blank lines.
1051         Sections where the second and all subsequent lines begin with a ">"
1052         or "|" character are considered "quoting sections". The first line of
1053         the first non-quoting section becomes the summary of the message. 
1055         If keep_citations is true, then we keep the "quoting sections" in the
1056         content.
1057         If keep_body is true, we even keep the signature sections.
1058     '''
1059     # strip off leading carriage-returns / newlines
1060     i = 0
1061     for i in range(len(content)):
1062         if content[i] not in '\r\n':
1063             break
1064     if i > 0:
1065         sections = blank_line.split(content[i:])
1066     else:
1067         sections = blank_line.split(content)
1069     # extract out the summary from the message
1070     summary = ''
1071     l = []
1072     for section in sections:
1073         #section = section.strip()
1074         if not section:
1075             continue
1076         lines = eol.split(section)
1077         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1078                 lines[1] and lines[1][0] in '>|'):
1079             # see if there's a response somewhere inside this section (ie.
1080             # no blank line between quoted message and response)
1081             for line in lines[1:]:
1082                 if line and line[0] not in '>|':
1083                     break
1084             else:
1085                 # we keep quoted bits if specified in the config
1086                 if keep_citations:
1087                     l.append(section)
1088                 continue
1089             # keep this section - it has reponse stuff in it
1090             lines = lines[lines.index(line):]
1091             section = '\n'.join(lines)
1092             # and while we're at it, use the first non-quoted bit as
1093             # our summary
1094             summary = section
1096         if not summary:
1097             # if we don't have our summary yet use the first line of this
1098             # section
1099             summary = section
1100         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1101             # lose any signature
1102             break
1103         elif original_msg.match(lines[0]):
1104             # ditch the stupid Outlook quoting of the entire original message
1105             break
1107         # and add the section to the output
1108         l.append(section)
1110     # figure the summary - find the first sentence-ending punctuation or the
1111     # first whole line, whichever is longest
1112     sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1113     if sentence:
1114         sentence = sentence.group(1)
1115     else:
1116         sentence = ''
1117     first = eol.split(summary)[0]
1118     summary = max(sentence, first)
1120     # Now reconstitute the message content minus the bits we don't care
1121     # about.
1122     if not keep_body:
1123         content = '\n\n'.join(l)
1125     return summary, content
1127 # vim: set filetype=python ts=4 sw=4 et si