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