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