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