Code

Added users' timezone support
[roundup.git] / roundup / mailgw.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17 #
19 '''
20 An e-mail gateway for Roundup.
22 Incoming messages are examined for multiple parts:
23  . In a multipart/mixed message or part, each subpart is extracted and
24    examined. The text/plain subparts are assembled to form the textual
25    body of the message, to be stored in the file associated with a "msg"
26    class node. Any parts of other types are each stored in separate files
27    and given "file" class nodes that are linked to the "msg" node. 
28  . In a multipart/alternative message or part, we look for a text/plain
29    subpart and ignore the other parts.
31 Summary
32 -------
33 The "summary" property on message nodes is taken from the first non-quoting
34 section in the message body. The message body is divided into sections by
35 blank lines. Sections where the second and all subsequent lines begin with
36 a ">" or "|" character are considered "quoting sections". The first line of
37 the first non-quoting section becomes the summary of the message. 
39 Addresses
40 ---------
41 All of the addresses in the To: and Cc: headers of the incoming message are
42 looked up among the user nodes, and the corresponding users are placed in
43 the "recipients" property on the new "msg" node. The address in the From:
44 header similarly determines the "author" property of the new "msg"
45 node. The default handling for addresses that don't have corresponding
46 users is to create new users with no passwords and a username equal to the
47 address. (The web interface does not permit logins for users with no
48 passwords.) If we prefer to reject mail from outside sources, we can simply
49 register an auditor on the "user" class that prevents the creation of user
50 nodes with no passwords. 
52 Actions
53 -------
54 The subject line of the incoming message is examined to determine whether
55 the message is an attempt to create a new item or to discuss an existing
56 item. A designator enclosed in square brackets is sought as the first thing
57 on the subject line (after skipping any "Fwd:" or "Re:" prefixes). 
59 If an item designator (class name and id number) is found there, the newly
60 created "msg" node is added to the "messages" property for that item, and
61 any new "file" nodes are added to the "files" property for the item. 
63 If just an item class name is found there, we attempt to create a new item
64 of that class with its "messages" property initialized to contain the new
65 "msg" node and its "files" property initialized to contain any new "file"
66 nodes. 
68 Triggers
69 --------
70 Both cases may trigger detectors (in the first case we are calling the
71 set() method to add the message to the item's spool; in the second case we
72 are calling the create() method to create a new node). If an auditor raises
73 an exception, the original message is bounced back to the sender with the
74 explanatory message given in the exception. 
76 $Id: mailgw.py,v 1.108 2003-01-27 16:32:46 kedder Exp $
77 '''
79 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
80 import time, random, sys
81 import traceback, MimeWriter
82 import hyperdb, date, password
84 import rfc2822
86 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
88 class MailGWError(ValueError):
89     pass
91 class MailUsageError(ValueError):
92     pass
94 class MailUsageHelp(Exception):
95     pass
97 class MailLoop(Exception):
98     ''' We've seen this message before... '''
99     pass
101 class Unauthorized(Exception):
102     """ Access denied """
104 def initialiseSecurity(security):
105     ''' Create some Permissions and Roles on the security object
107         This function is directly invoked by security.Security.__init__()
108         as a part of the Security object instantiation.
109     '''
110     security.addPermission(name="Email Registration",
111         description="Anonymous may register through e-mail")
112     p = security.addPermission(name="Email Access",
113         description="User may use the email interface")
114     security.addPermissionToRole('Admin', p)
116 class Message(mimetools.Message):
117     ''' subclass mimetools.Message so we can retrieve the parts of the
118         message...
119     '''
120     def getPart(self):
121         ''' Get a single part of a multipart message and return it as a new
122             Message instance.
123         '''
124         boundary = self.getparam('boundary')
125         mid, end = '--'+boundary, '--'+boundary+'--'
126         s = cStringIO.StringIO()
127         while 1:
128             line = self.fp.readline()
129             if not line:
130                 break
131             if line.strip() in (mid, end):
132                 break
133             s.write(line)
134         if not s.getvalue().strip():
135             return None
136         s.seek(0)
137         return Message(s)
139     def getheader(self, name, default=None):
140         hdr = mimetools.Message.getheader(self, name, default)
141         return rfc2822.decode_header(hdr)
142  
143 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*'
144     r'\s*(?P<quote>")?(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
145     r'\s*(?P<title>[^[]+)?"?(\[(?P<args>.+?)\])?', re.I)
147 class MailGW:
148     def __init__(self, instance, db, arguments={}):
149         self.instance = instance
150         self.db = db
151         self.arguments = {}
153         # should we trap exceptions (normal usage) or pass them through
154         # (for testing)
155         self.trapExceptions = 1
157     def do_pipe(self):
158         ''' Read a message from standard input and pass it to the mail handler.
160             Read into an internal structure that we can seek on (in case
161             there's an error).
163             XXX: we may want to read this into a temporary file instead...
164         '''
165         s = cStringIO.StringIO()
166         s.write(sys.stdin.read())
167         s.seek(0)
168         self.main(s)
169         return 0
171     def do_mailbox(self, filename):
172         ''' Read a series of messages from the specified unix mailbox file and
173             pass each to the mail handler.
174         '''
175         # open the spool file and lock it
176         import fcntl, FCNTL
177         f = open(filename, 'r+')
178         fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
180         # handle and clear the mailbox
181         try:
182             from mailbox import UnixMailbox
183             mailbox = UnixMailbox(f, factory=Message)
184             # grab one message
185             message = mailbox.next()
186             while message:
187                 # handle this message
188                 self.handle_Message(message)
189                 message = mailbox.next()
190             # nuke the file contents
191             os.ftruncate(f.fileno(), 0)
192         except:
193             import traceback
194             traceback.print_exc()
195             return 1
196         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
197         return 0
199     def do_pop(self, server, user='', password=''):
200         '''Read a series of messages from the specified POP server.
201         '''
202         import getpass, poplib, socket
203         try:
204             if not user:
205                 user = raw_input(_('User: '))
206             if not password:
207                 password = getpass.getpass()
208         except (KeyboardInterrupt, EOFError):
209             # Ctrl C or D maybe also Ctrl Z under Windows.
210             print "\nAborted by user."
211             return 1
213         # open a connection to the server and retrieve all messages
214         try:
215             server = poplib.POP3(server)
216         except socket.error, message:
217             print "POP server error:", message
218             return 1
219         server.user(user)
220         server.pass_(password)
221         numMessages = len(server.list()[1])
222         for i in range(1, numMessages+1):
223             # retr: returns 
224             # [ pop response e.g. '+OK 459 octets',
225             #   [ array of message lines ],
226             #   number of octets ]
227             lines = server.retr(i)[1]
228             s = cStringIO.StringIO('\n'.join(lines))
229             s.seek(0)
230             self.handle_Message(Message(s))
231             # delete the message
232             server.dele(i)
234         # quit the server to commit changes.
235         server.quit()
236         return 0
238     def main(self, fp):
239         ''' fp - the file from which to read the Message.
240         '''
241         return self.handle_Message(Message(fp))
243     def handle_Message(self, message):
244         '''Handle an RFC822 Message
246         Handle the Message object by calling handle_message() and then cope
247         with any errors raised by handle_message.
248         This method's job is to make that call and handle any
249         errors in a sane manner. It should be replaced if you wish to
250         handle errors in a different manner.
251         '''
252         # in some rare cases, a particularly stuffed-up e-mail will make
253         # its way into here... try to handle it gracefully
254         sendto = message.getaddrlist('from')
255         if sendto:
256             if not self.trapExceptions:
257                 return self.handle_message(message)
258             try:
259                 return self.handle_message(message)
260             except MailUsageHelp:
261                 # bounce the message back to the sender with the usage message
262                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
263                 sendto = [sendto[0][1]]
264                 m = ['']
265                 m.append('\n\nMail Gateway Help\n=================')
266                 m.append(fulldoc)
267                 m = self.bounce_message(message, sendto, m,
268                     subject="Mail Gateway Help")
269             except MailUsageError, value:
270                 # bounce the message back to the sender with the usage message
271                 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
272                 sendto = [sendto[0][1]]
273                 m = ['']
274                 m.append(str(value))
275                 m.append('\n\nMail Gateway Help\n=================')
276                 m.append(fulldoc)
277                 m = self.bounce_message(message, sendto, m)
278             except Unauthorized, value:
279                 # just inform the user that he is not authorized
280                 sendto = [sendto[0][1]]
281                 m = ['']
282                 m.append(str(value))
283                 m = self.bounce_message(message, sendto, m)
284             except MailLoop:
285                 # XXX we should use a log file here...
286                 return
287             except:
288                 # bounce the message back to the sender with the error message
289                 # XXX we should use a log file here...
290                 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
291                 m = ['']
292                 m.append('An unexpected error occurred during the processing')
293                 m.append('of your message. The tracker administrator is being')
294                 m.append('notified.\n')
295                 m.append('----  traceback of failure  ----')
296                 s = cStringIO.StringIO()
297                 import traceback
298                 traceback.print_exc(None, s)
299                 m.append(s.getvalue())
300                 m = self.bounce_message(message, sendto, m)
301         else:
302             # very bad-looking message - we don't even know who sent it
303             # XXX we should use a log file here...
304             sendto = [self.instance.config.ADMIN_EMAIL]
305             m = ['Subject: badly formed message from mail gateway']
306             m.append('')
307             m.append('The mail gateway retrieved a message which has no From:')
308             m.append('line, indicating that it is corrupt. Please check your')
309             m.append('mail gateway source. Failed message is attached.')
310             m.append('')
311             m = self.bounce_message(message, sendto, m,
312                 subject='Badly formed message from mail gateway')
314         # now send the message
315         if SENDMAILDEBUG:
316             open(SENDMAILDEBUG, 'a').write('From: %s\nTo: %s\n%s\n'%(
317                 self.instance.config.ADMIN_EMAIL, ', '.join(sendto),
318                     m.getvalue()))
319         else:
320             try:
321                 smtp = smtplib.SMTP(self.instance.config.MAILHOST)
322                 smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto,
323                     m.getvalue())
324             except socket.error, value:
325                 raise MailGWError, "Couldn't send error email: "\
326                     "mailhost %s"%value
327             except smtplib.SMTPException, value:
328                 raise MailGWError, "Couldn't send error email: %s"%value
330     def bounce_message(self, message, sendto, error,
331             subject='Failed issue tracker submission'):
332         ''' create a message that explains the reason for the failed
333             issue submission to the author and attach the original
334             message.
335         '''
336         msg = cStringIO.StringIO()
337         writer = MimeWriter.MimeWriter(msg)
338         writer.addheader('X-Roundup-Loop', 'hello')
339         writer.addheader('Subject', subject)
340         writer.addheader('From', '%s <%s>'% (self.instance.config.TRACKER_NAME,
341             self.instance.config.TRACKER_EMAIL))
342         writer.addheader('To', ','.join(sendto))
343         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
344             time.gmtime()))
345         writer.addheader('MIME-Version', '1.0')
346         part = writer.startmultipartbody('mixed')
347         part = writer.nextpart()
348         body = part.startbody('text/plain; charset=utf-8')
349         body.write('\n'.join(error))
351         # attach the original message to the returned message
352         part = writer.nextpart()
353         part.addheader('Content-Disposition','attachment')
354         part.addheader('Content-Description','Message you sent')
355         body = part.startbody('text/plain')
356         for header in message.headers:
357             body.write(header)
358         body.write('\n')
359         try:
360             message.rewindbody()
361         except IOError, message:
362             body.write("*** couldn't include message body: %s ***"%message)
363         else:
364             body.write(message.fp.read())
366         writer.lastpart()
367         return msg
369     def get_part_data_decoded(self,part):
370         encoding = part.getencoding()
371         data = None
372         if encoding == 'base64':
373             # BUG: is base64 really used for text encoding or
374             # are we inserting zip files here. 
375             data = binascii.a2b_base64(part.fp.read())
376         elif encoding == 'quoted-printable':
377             # the quopri module wants to work with files
378             decoded = cStringIO.StringIO()
379             quopri.decode(part.fp, decoded)
380             data = decoded.getvalue()
381         elif encoding == 'uuencoded':
382             data = binascii.a2b_uu(part.fp.read())
383         else:
384             # take it as text
385             data = part.fp.read()
386         
387         # Encode message to unicode
388         charset = rfc2822.unaliasCharset(part.getparam("charset"))
389         if charset:
390             # Do conversion only if charset specified
391             edata = unicode(data, charset).encode('utf-8')
392             # Convert from dos eol to unix
393             edata = edata.replace('\r\n', '\n')
394         else:
395             # Leave message content as is
396             edata = data
397                 
398         return edata
400     def handle_message(self, message):
401         ''' message - a Message instance
403         Parse the message as per the module docstring.
404         '''
405         # detect loops
406         if message.getheader('x-roundup-loop', ''):
407             raise MailLoop
409         # XXX Don't enable. This doesn't work yet.
410 #  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
411         # handle delivery to addresses like:tracker+issue25@some.dom.ain
412         # use the embedded issue number as our issue
413 #        if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
414 #                self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
415 #            issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
416 #            for header in ['to', 'cc', 'bcc']:
417 #                addresses = message.getheader(header, '')
418 #            if addresses:
419 #              # FIXME, this only finds the first match in the addresses.
420 #                issue = re.search(issue_re, addresses, 'i')
421 #                if issue:
422 #                    classname = issue.group('classname')
423 #                    nodeid = issue.group('nodeid')
424 #                    break
426         # handle the subject line
427         subject = message.getheader('subject', '')
429         if subject.strip().lower() == 'help':
430             raise MailUsageHelp
432         m = subject_re.match(subject)
434         # check for well-formed subject line
435         if m:
436             # get the classname
437             classname = m.group('classname')
438             if classname is None:
439                 # no classname, fallback on the default
440                 if hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
441                         self.instance.config.MAIL_DEFAULT_CLASS:
442                     classname = self.instance.config.MAIL_DEFAULT_CLASS
443                 else:
444                     # fail
445                     m = None
447         if not m:
448             raise MailUsageError, '''
449 The message you sent to roundup did not contain a properly formed subject
450 line. The subject must contain a class name or designator to indicate the
451 "topic" of the message. For example:
452     Subject: [issue] This is a new issue
453       - this will create a new issue in the tracker with the title "This is
454         a new issue".
455     Subject: [issue1234] This is a followup to issue 1234
456       - this will append the message's contents to the existing issue 1234
457         in the tracker.
459 Subject was: "%s"
460 '''%subject
462         # get the class
463         try:
464             cl = self.db.getclass(classname)
465         except KeyError:
466             raise MailUsageError, '''
467 The class name you identified in the subject line ("%s") does not exist in the
468 database.
470 Valid class names are: %s
471 Subject was: "%s"
472 '''%(classname, ', '.join(self.db.getclasses()), subject)
474         # get the optional nodeid
475         nodeid = m.group('nodeid')
477         # title is optional too
478         title = m.group('title')
479         if title:
480             title = title.strip()
481         else:
482             title = ''
484         # strip off the quotes that dumb emailers put around the subject, like
485         #      Re: "[issue1] bla blah"
486         if m.group('quote') and title.endswith('"'):
487             title = title[:-1]
489         # but we do need either a title or a nodeid...
490         if nodeid is None and not title:
491             raise MailUsageError, '''
492 I cannot match your message to a node in the database - you need to either
493 supply a full node identifier (with number, eg "[issue123]" or keep the
494 previous subject title intact so I can match that.
496 Subject was: "%s"
497 '''%subject
499         # If there's no nodeid, check to see if this is a followup and
500         # maybe someone's responded to the initial mail that created an
501         # entry. Try to find the matching nodes with the same title, and
502         # use the _last_ one matched (since that'll _usually_ be the most
503         # recent...)
504         if nodeid is None and m.group('refwd'):
505             l = cl.stringFind(title=title)
506             if l:
507                 nodeid = l[-1]
509         # if a nodeid was specified, make sure it's valid
510         if nodeid is not None and not cl.hasnode(nodeid):
511             raise MailUsageError, '''
512 The node specified by the designator in the subject of your message ("%s")
513 does not exist.
515 Subject was: "%s"
516 '''%(nodeid, subject)
519         # Handle the arguments specified by the email gateway command line.
520         # We do this by looping over the list of self.arguments looking for
521         # a -C to tell us what class then the -S setting string.
522         msg_props = {}
523         user_props = {}
524         file_props = {}
525         issue_props = {}
526         # so, if we have any arguments, use them
527         if self.arguments:
528             current_class = 'msg'
529             for option, propstring in self.arguments:
530                 if option in ( '-C', '--class'):
531                     current_class = propstring.strip()
532                     if current_class not in ('msg', 'file', 'user', 'issue'):
533                         raise MailUsageError, '''
534 The mail gateway is not properly set up. Please contact
535 %s and have them fix the incorrect class specified as:
536   %s
537 '''%(self.instance.config.ADMIN_EMAIL, current_class)
538                 if option in ('-S', '--set'):
539                     if current_class == 'issue' :
540                         errors, issue_props = setPropArrayFromString(self,
541                             cl, propstring.strip(), nodeid)
542                     elif current_class == 'file' :
543                         temp_cl = self.db.getclass('file')
544                         errors, file_props = setPropArrayFromString(self,
545                             temp_cl, propstring.strip())
546                     elif current_class == 'msg' :
547                         temp_cl = self.db.getclass('msg')
548                         errors, msg_props = setPropArrayFromString(self,
549                             temp_cl, propstring.strip())
550                     elif current_class == 'user' :
551                         temp_cl = self.db.getclass('user')
552                         errors, user_props = setPropArrayFromString(self,
553                             temp_cl, propstring.strip())
554                     if errors:
555                         raise MailUsageError, '''
556 The mail gateway is not properly set up. Please contact
557 %s and have them fix the incorrect properties:
558   %s
559 '''%(self.instance.config.ADMIN_EMAIL, errors)
561         #
562         # handle the users
563         #
564         # Don't create users if anonymous isn't allowed to register
565         create = 1
566         anonid = self.db.user.lookup('anonymous')
567         if not self.db.security.hasPermission('Email Registration', anonid):
568             create = 0
570         # ok, now figure out who the author is - create a new user if the
571         # "create" flag is true
572         author = uidFromAddress(self.db, message.getaddrlist('from')[0],
573             create=create)
575         # if we're not recognised, and we don't get added as a user, then we
576         # must be anonymous
577         if not author:
578             author = anonid
580         # make sure the author has permission to use the email interface
581         if not self.db.security.hasPermission('Email Access', author):
582             if author == anonid:
583                 # we're anonymous and we need to be a registered user
584                 raise Unauthorized, '''
585 You are not a registered user.
587 Unknown address: %s
588 '''%message.getaddrlist('from')[0][1]
589             else:
590                 # we're registered and we're _still_ not allowed access
591                 raise Unauthorized, 'You are not permitted to access '\
592                     'this tracker.'
594         # make sure they're allowed to edit this class of information
595         if not self.db.security.hasPermission('Edit', author, classname):
596             raise Unauthorized, 'You are not permitted to edit %s.'%classname
598         # the author may have been created - make sure the change is
599         # committed before we reopen the database
600         self.db.commit()
602         # reopen the database as the author
603         username = self.db.user.get(author, 'username')
604         self.db.close()
605         self.db = self.instance.open(username)
607         # re-get the class with the new database connection
608         cl = self.db.getclass(classname)
610         # now update the recipients list
611         recipients = []
612         tracker_email = self.instance.config.TRACKER_EMAIL.lower()
613         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
614             r = recipient[1].strip().lower()
615             if r == tracker_email or not r:
616                 continue
618             # look up the recipient - create if necessary (and we're
619             # allowed to)
620             recipient = uidFromAddress(self.db, recipient, create, **user_props)
622             # if all's well, add the recipient to the list
623             if recipient:
624                 recipients.append(recipient)
626         #
627         # XXX extract the args NOT USED WHY -- rouilj
628         #
629         subject_args = m.group('args')
631         #
632         # handle the subject argument list
633         #
634         # figure what the properties of this Class are
635         properties = cl.getprops()
636         props = {}
637         args = m.group('args')
638         if args:
639             errors, props = setPropArrayFromString(self, cl, args, nodeid)
640             # handle any errors parsing the argument list
641             if errors:
642                 errors = '\n- '.join(errors)
643                 raise MailUsageError, '''
644 There were problems handling your subject line argument list:
645 - %s
647 Subject was: "%s"
648 '''%(errors, subject)
650         #
651         # handle message-id and in-reply-to
652         #
653         messageid = message.getheader('message-id')
654         inreplyto = message.getheader('in-reply-to') or ''
655         # generate a messageid if there isn't one
656         if not messageid:
657             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
658                 classname, nodeid, self.instance.config.MAIL_DOMAIN)
660         #
661         # now handle the body - find the message
662         #
663         content_type =  message.gettype()
664         attachments = []
665         # General multipart handling:
666         #   Take the first text/plain part, anything else is considered an 
667         #   attachment.
668         # multipart/mixed: multiple "unrelated" parts.
669         # multipart/signed (rfc 1847): 
670         #   The control information is carried in the second of the two 
671         #   required body parts.
672         #   ACTION: Default, so if content is text/plain we get it.
673         # multipart/encrypted (rfc 1847): 
674         #   The control information is carried in the first of the two 
675         #   required body parts.
676         #   ACTION: Not handleable as the content is encrypted.
677         # multipart/related (rfc 1872, 2112, 2387):
678         #   The Multipart/Related content-type addresses the MIME
679         #   representation of compound objects.
680         #   ACTION: Default. If we are lucky there is a text/plain.
681         #   TODO: One should use the start part and look for an Alternative
682         #   that is text/plain.
683         # multipart/Alternative (rfc 1872, 1892):
684         #   only in "related" ?
685         # multipart/report (rfc 1892):
686         #   e.g. mail system delivery status reports.
687         #   ACTION: Default. Could be ignored or used for Delivery Notification 
688         #   flagging.
689         # multipart/form-data:
690         #   For web forms only.
691         if content_type == 'multipart/mixed':
692             # skip over the intro to the first boundary
693             part = message.getPart()
694             content = None
695             while 1:
696                 # get the next part
697                 part = message.getPart()
698                 if part is None:
699                     break
700                 # parse it
701                 subtype = part.gettype()
702                 if subtype == 'text/plain' and not content:
703                     # The first text/plain part is the message content.
704                     content = self.get_part_data_decoded(part) 
705                 elif subtype == 'message/rfc822':
706                     # handle message/rfc822 specially - the name should be
707                     # the subject of the actual e-mail embedded here
708                     i = part.fp.tell()
709                     mailmess = Message(part.fp)
710                     name = mailmess.getheader('subject')
711                     part.fp.seek(i)
712                     attachments.append((name, 'message/rfc822', part.fp.read()))
713                 elif subtype == 'multipart/alternative':
714                     # Search for text/plain in message with attachment and
715                     # alternative text representation
716                     part.getPart()
717                     while 1:
718                         # get the next part
719                         subpart = part.getPart()
720                         if subpart is None:
721                             break
722                         # parse it
723                         if subpart.gettype() == 'text/plain' and not content:
724                             content = self.get_part_data_decoded(subpart) 
725                 else:
726                     # try name on Content-Type
727                     name = part.getparam('name')
728                     if name:
729                         name = name.strip()
730                     if not name:
731                         disp = part.getheader('content-disposition', None)
732                         if disp:
733                             name = disp.getparam('filename')
734                             if name:
735                                 name = name.strip()
736                     # this is just an attachment
737                     data = self.get_part_data_decoded(part) 
738                     attachments.append((name, part.gettype(), data))
739             if content is None:
740                 raise MailUsageError, '''
741 Roundup requires the submission to be plain text. The message parser could
742 not find a text/plain part to use.
743 '''
745         elif content_type[:10] == 'multipart/':
746             # skip over the intro to the first boundary
747             message.getPart()
748             content = None
749             while 1:
750                 # get the next part
751                 part = message.getPart()
752                 if part is None:
753                     break
754                 # parse it
755                 if part.gettype() == 'text/plain' and not content:
756                     content = self.get_part_data_decoded(part) 
757             if content is None:
758                 raise MailUsageError, '''
759 Roundup requires the submission to be plain text. The message parser could
760 not find a text/plain part to use.
761 '''
763         elif content_type != 'text/plain':
764             raise MailUsageError, '''
765 Roundup requires the submission to be plain text. The message parser could
766 not find a text/plain part to use.
767 '''
769         else:
770             content = self.get_part_data_decoded(message) 
771  
772         # figure how much we should muck around with the email body
773         keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
774             'no') == 'yes'
775         keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
776             'no') == 'yes'
778         # parse the body of the message, stripping out bits as appropriate
779         summary, content = parseContent(content, keep_citations, 
780             keep_body)
782         # 
783         # handle the attachments
784         #
785         files = []
786         for (name, mime_type, data) in attachments:
787             if not name:
788                 name = "unnamed"
789             files.append(self.db.file.create(type=mime_type, name=name,
790                 content=data, **file_props))
792         # 
793         # create the message if there's a message body (content)
794         #
795         if content:
796             message_id = self.db.msg.create(author=author,
797                 recipients=recipients, date=date.Date('.'), summary=summary,
798                 content=content, files=files, messageid=messageid,
799                 inreplyto=inreplyto, **msg_props)
801             # attach the message to the node
802             if nodeid:
803                 # add the message to the node's list
804                 messages = cl.get(nodeid, 'messages')
805                 messages.append(message_id)
806                 props['messages'] = messages
807             else:
808                 # pre-load the messages list
809                 props['messages'] = [message_id]
811                 # set the title to the subject
812                 if properties.has_key('title') and not props.has_key('title'):
813                     props['title'] = title
815         #
816         # perform the node change / create
817         #
818         try:
819             # merge the command line props defined in issue_props into
820             # the props dictionary because function(**props, **issue_props)
821             # is a syntax error.
822             for prop in issue_props.keys() :
823                 if not props.has_key(prop) :
824                     props[prop] = issue_props[prop]
825             if nodeid:
826                 cl.set(nodeid, **props)
827             else:
828                 nodeid = cl.create(**props)
829         except (TypeError, IndexError, ValueError), message:
830             raise MailUsageError, '''
831 There was a problem with the message you sent:
832    %s
833 '''%message
835         # commit the changes to the DB
836         self.db.commit()
838         return nodeid
840  
841 def setPropArrayFromString(self, cl, propString, nodeid = None):
842     ''' takes string of form prop=value,value;prop2=value
843         and returns (error, prop[..])
844     '''
845     properties = cl.getprops()
846     props = {}
847     errors = []
848     for prop in string.split(propString, ';'):
849         # extract the property name and value
850         try:
851             propname, value = prop.split('=')
852         except ValueError, message:
853             errors.append('not of form [arg=value,value,...;'
854                 'arg=value,value,...]')
855             return (errors, props)
857         # ensure it's a valid property name
858         propname = propname.strip()
859         try:
860             proptype =  properties[propname]
861         except KeyError:
862             errors.append('refers to an invalid property: "%s"'%propname)
863             continue
865         # convert the string value to a real property value
866         if isinstance(proptype, hyperdb.String):
867             props[propname] = value.strip()
868         if isinstance(proptype, hyperdb.Password):
869             props[propname] = password.Password(value.strip())
870         elif isinstance(proptype, hyperdb.Date):
871             try:
872                 props[propname] = date.Date(value.strip()).local(self.db.getUserTimezone())
873             except ValueError, message:
874                 errors.append('contains an invalid date for %s.'%propname)
875         elif isinstance(proptype, hyperdb.Interval):
876             try:
877                 props[propname] = date.Interval(value)
878             except ValueError, message:
879                 errors.append('contains an invalid date interval for %s.'%
880                     propname)
881         elif isinstance(proptype, hyperdb.Link):
882             linkcl = self.db.classes[proptype.classname]
883             propkey = linkcl.labelprop(default_to_id=1)
884             try:
885                 props[propname] = linkcl.lookup(value)
886             except KeyError, message:
887                 errors.append('"%s" is not a value for %s.'%(value, propname))
888         elif isinstance(proptype, hyperdb.Multilink):
889             # get the linked class
890             linkcl = self.db.classes[proptype.classname]
891             propkey = linkcl.labelprop(default_to_id=1)
892             if nodeid:
893                 curvalue = cl.get(nodeid, propname)
894             else:
895                 curvalue = []
897             # handle each add/remove in turn
898             # keep an extra list for all items that are
899             # definitely in the new list (in case of e.g.
900             # <propname>=A,+B, which should replace the old
901             # list with A,B)
902             set = 0
903             newvalue = []
904             for item in value.split(','):
905                 item = item.strip()
907                 # handle +/-
908                 remove = 0
909                 if item.startswith('-'):
910                     remove = 1
911                     item = item[1:]
912                 elif item.startswith('+'):
913                     item = item[1:]
914                 else:
915                     set = 1
917                 # look up the value
918                 try:
919                     item = linkcl.lookup(item)
920                 except KeyError, message:
921                     errors.append('"%s" is not a value for %s.'%(item,
922                         propname))
923                     continue
925                 # perform the add/remove
926                 if remove:
927                     try:
928                         curvalue.remove(item)
929                     except ValueError:
930                         errors.append('"%s" is not currently in for %s.'%(item,
931                             propname))
932                         continue
933                 else:
934                     newvalue.append(item)
935                     if item not in curvalue:
936                         curvalue.append(item)
938             # that's it, set the new Multilink property value,
939             # or overwrite it completely
940             if set:
941                 props[propname] = newvalue
942             else:
943                 props[propname] = curvalue
944         elif isinstance(proptype, hyperdb.Boolean):
945             value = value.strip()
946             props[propname] = value.lower() in ('yes', 'true', 'on', '1')
947         elif isinstance(proptype, hyperdb.Number):
948             value = value.strip()
949             props[propname] = int(value)
950     return errors, props
953 def extractUserFromList(userClass, users):
954     '''Given a list of users, try to extract the first non-anonymous user
955        and return that user, otherwise return None
956     '''
957     if len(users) > 1:
958         for user in users:
959             # make sure we don't match the anonymous or admin user
960             if userClass.get(user, 'username') in ('admin', 'anonymous'):
961                 continue
962             # first valid match will do
963             return user
964         # well, I guess we have no choice
965         return user[0]
966     elif users:
967         return users[0]
968     return None
971 def uidFromAddress(db, address, create=1, **user_props):
972     ''' address is from the rfc822 module, and therefore is (name, addr)
974         user is created if they don't exist in the db already
975         user_props may supply additional user information
976     '''
977     (realname, address) = address
979     # try a straight match of the address
980     user = extractUserFromList(db.user, db.user.stringFind(address=address))
981     if user is not None: return user
983     # try the user alternate addresses if possible
984     props = db.user.getprops()
985     if props.has_key('alternate_addresses'):
986         users = db.user.filter(None, {'alternate_addresses': address})
987         user = extractUserFromList(db.user, users)
988         if user is not None: return user
990     # try to match the username to the address (for local
991     # submissions where the address is empty)
992     user = extractUserFromList(db.user, db.user.stringFind(username=address))
994     # couldn't match address or username, so create a new user
995     if create:
996         return db.user.create(username=address, address=address,
997             realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
998             **user_props)
999     else:
1000         return 0
1003 def parseContent(content, keep_citations, keep_body,
1004         blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
1005         eol=re.compile(r'[\r\n]+'), 
1006         signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
1007         original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
1008     ''' The message body is divided into sections by blank lines.
1009         Sections where the second and all subsequent lines begin with a ">"
1010         or "|" character are considered "quoting sections". The first line of
1011         the first non-quoting section becomes the summary of the message. 
1013         If keep_citations is true, then we keep the "quoting sections" in the
1014         content.
1015         If keep_body is true, we even keep the signature sections.
1016     '''
1017     # strip off leading carriage-returns / newlines
1018     i = 0
1019     for i in range(len(content)):
1020         if content[i] not in '\r\n':
1021             break
1022     if i > 0:
1023         sections = blank_line.split(content[i:])
1024     else:
1025         sections = blank_line.split(content)
1027     # extract out the summary from the message
1028     summary = ''
1029     l = []
1030     for section in sections:
1031         #section = section.strip()
1032         if not section:
1033             continue
1034         lines = eol.split(section)
1035         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1036                 lines[1] and lines[1][0] in '>|'):
1037             # see if there's a response somewhere inside this section (ie.
1038             # no blank line between quoted message and response)
1039             for line in lines[1:]:
1040                 if line and line[0] not in '>|':
1041                     break
1042             else:
1043                 # we keep quoted bits if specified in the config
1044                 if keep_citations:
1045                     l.append(section)
1046                 continue
1047             # keep this section - it has reponse stuff in it
1048             lines = lines[lines.index(line):]
1049             section = '\n'.join(lines)
1050             # and while we're at it, use the first non-quoted bit as
1051             # our summary
1052             summary = section
1054         if not summary:
1055             # if we don't have our summary yet use the first line of this
1056             # section
1057             summary = section
1058         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1059             # lose any signature
1060             break
1061         elif original_msg.match(lines[0]):
1062             # ditch the stupid Outlook quoting of the entire original message
1063             break
1065         # and add the section to the output
1066         l.append(section)
1068     # figure the summary - find the first sentence-ending punctuation or the
1069     # first whole line, whichever is longest
1070     sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1071     if sentence:
1072         sentence = sentence.group(1)
1073     else:
1074         sentence = ''
1075     first = eol.split(summary)[0]
1076     summary = max(sentence, first)
1078     # Now reconstitute the message content minus the bits we don't care
1079     # about.
1080     if not keep_body:
1081         content = '\n\n'.join(l)
1083     return summary, content
1085 # vim: set filetype=python ts=4 sw=4 et si