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