1a5332bb99027c4fed6a2378da084d617b205e0e
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.115 2003-04-17 03:37:59 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))
821 #
822 # create the message if there's a message body (content)
823 #
824 if content:
825 message_id = self.db.msg.create(author=author,
826 recipients=recipients, date=date.Date('.'), summary=summary,
827 content=content, files=files, messageid=messageid,
828 inreplyto=inreplyto, **msg_props)
830 # attach the message to the node
831 if nodeid:
832 # add the message to the node's list
833 messages = cl.get(nodeid, 'messages')
834 messages.append(message_id)
835 props['messages'] = messages
836 else:
837 # pre-load the messages list
838 props['messages'] = [message_id]
840 # set the title to the subject
841 if properties.has_key('title') and not props.has_key('title'):
842 props['title'] = title
844 #
845 # perform the node change / create
846 #
847 try:
848 # merge the command line props defined in issue_props into
849 # the props dictionary because function(**props, **issue_props)
850 # is a syntax error.
851 for prop in issue_props.keys() :
852 if not props.has_key(prop) :
853 props[prop] = issue_props[prop]
854 if nodeid:
855 cl.set(nodeid, **props)
856 else:
857 nodeid = cl.create(**props)
858 except (TypeError, IndexError, ValueError), message:
859 raise MailUsageError, '''
860 There was a problem with the message you sent:
861 %s
862 '''%message
864 # commit the changes to the DB
865 self.db.commit()
867 return nodeid
870 def setPropArrayFromString(self, cl, propString, nodeid = None):
871 ''' takes string of form prop=value,value;prop2=value
872 and returns (error, prop[..])
873 '''
874 properties = cl.getprops()
875 props = {}
876 errors = []
877 for prop in string.split(propString, ';'):
878 # extract the property name and value
879 try:
880 propname, value = prop.split('=')
881 except ValueError, message:
882 errors.append('not of form [arg=value,value,...;'
883 'arg=value,value,...]')
884 return (errors, props)
886 # ensure it's a valid property name
887 propname = propname.strip()
888 try:
889 proptype = properties[propname]
890 except KeyError:
891 errors.append('refers to an invalid property: "%s"'%propname)
892 continue
894 # convert the string value to a real property value
895 if isinstance(proptype, hyperdb.String):
896 props[propname] = value.strip()
897 if isinstance(proptype, hyperdb.Password):
898 props[propname] = password.Password(value.strip())
899 elif isinstance(proptype, hyperdb.Date):
900 try:
901 props[propname] = date.Date(value.strip()).local(self.db.getUserTimezone())
902 except ValueError, message:
903 errors.append('contains an invalid date for %s.'%propname)
904 elif isinstance(proptype, hyperdb.Interval):
905 try:
906 props[propname] = date.Interval(value)
907 except ValueError, message:
908 errors.append('contains an invalid date interval for %s.'%
909 propname)
910 elif isinstance(proptype, hyperdb.Link):
911 linkcl = self.db.classes[proptype.classname]
912 propkey = linkcl.labelprop(default_to_id=1)
913 try:
914 props[propname] = linkcl.lookup(value)
915 except KeyError, message:
916 errors.append('"%s" is not a value for %s.'%(value, propname))
917 elif isinstance(proptype, hyperdb.Multilink):
918 # get the linked class
919 linkcl = self.db.classes[proptype.classname]
920 propkey = linkcl.labelprop(default_to_id=1)
921 if nodeid:
922 curvalue = cl.get(nodeid, propname)
923 else:
924 curvalue = []
926 # handle each add/remove in turn
927 # keep an extra list for all items that are
928 # definitely in the new list (in case of e.g.
929 # <propname>=A,+B, which should replace the old
930 # list with A,B)
931 set = 0
932 newvalue = []
933 for item in value.split(','):
934 item = item.strip()
936 # handle +/-
937 remove = 0
938 if item.startswith('-'):
939 remove = 1
940 item = item[1:]
941 elif item.startswith('+'):
942 item = item[1:]
943 else:
944 set = 1
946 # look up the value
947 try:
948 item = linkcl.lookup(item)
949 except KeyError, message:
950 errors.append('"%s" is not a value for %s.'%(item,
951 propname))
952 continue
954 # perform the add/remove
955 if remove:
956 try:
957 curvalue.remove(item)
958 except ValueError:
959 errors.append('"%s" is not currently in for %s.'%(item,
960 propname))
961 continue
962 else:
963 newvalue.append(item)
964 if item not in curvalue:
965 curvalue.append(item)
967 # that's it, set the new Multilink property value,
968 # or overwrite it completely
969 if set:
970 props[propname] = newvalue
971 else:
972 props[propname] = curvalue
973 elif isinstance(proptype, hyperdb.Boolean):
974 value = value.strip()
975 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
976 elif isinstance(proptype, hyperdb.Number):
977 value = value.strip()
978 props[propname] = float(value)
979 return errors, props
982 def extractUserFromList(userClass, users):
983 '''Given a list of users, try to extract the first non-anonymous user
984 and return that user, otherwise return None
985 '''
986 if len(users) > 1:
987 for user in users:
988 # make sure we don't match the anonymous or admin user
989 if userClass.get(user, 'username') in ('admin', 'anonymous'):
990 continue
991 # first valid match will do
992 return user
993 # well, I guess we have no choice
994 return user[0]
995 elif users:
996 return users[0]
997 return None
1000 def uidFromAddress(db, address, create=1, **user_props):
1001 ''' address is from the rfc822 module, and therefore is (name, addr)
1003 user is created if they don't exist in the db already
1004 user_props may supply additional user information
1005 '''
1006 (realname, address) = address
1008 # try a straight match of the address
1009 user = extractUserFromList(db.user, db.user.stringFind(address=address))
1010 if user is not None:
1011 return user
1013 # try the user alternate addresses if possible
1014 props = db.user.getprops()
1015 if props.has_key('alternate_addresses'):
1016 users = db.user.filter(None, {'alternate_addresses': address})
1017 user = extractUserFromList(db.user, users)
1018 if user is not None:
1019 return user
1021 # try to match the username to the address (for local
1022 # submissions where the address is empty)
1023 user = extractUserFromList(db.user, db.user.stringFind(username=address))
1025 # couldn't match address or username, so create a new user
1026 if create:
1027 return db.user.create(username=address, address=address,
1028 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1029 password=password.Password(password.generatePassword()),
1030 **user_props)
1031 else:
1032 return 0
1035 def parseContent(content, keep_citations, keep_body,
1036 blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
1037 eol=re.compile(r'[\r\n]+'),
1038 signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
1039 original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
1040 ''' The message body is divided into sections by blank lines.
1041 Sections where the second and all subsequent lines begin with a ">"
1042 or "|" character are considered "quoting sections". The first line of
1043 the first non-quoting section becomes the summary of the message.
1045 If keep_citations is true, then we keep the "quoting sections" in the
1046 content.
1047 If keep_body is true, we even keep the signature sections.
1048 '''
1049 # strip off leading carriage-returns / newlines
1050 i = 0
1051 for i in range(len(content)):
1052 if content[i] not in '\r\n':
1053 break
1054 if i > 0:
1055 sections = blank_line.split(content[i:])
1056 else:
1057 sections = blank_line.split(content)
1059 # extract out the summary from the message
1060 summary = ''
1061 l = []
1062 for section in sections:
1063 #section = section.strip()
1064 if not section:
1065 continue
1066 lines = eol.split(section)
1067 if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1068 lines[1] and lines[1][0] in '>|'):
1069 # see if there's a response somewhere inside this section (ie.
1070 # no blank line between quoted message and response)
1071 for line in lines[1:]:
1072 if line and line[0] not in '>|':
1073 break
1074 else:
1075 # we keep quoted bits if specified in the config
1076 if keep_citations:
1077 l.append(section)
1078 continue
1079 # keep this section - it has reponse stuff in it
1080 lines = lines[lines.index(line):]
1081 section = '\n'.join(lines)
1082 # and while we're at it, use the first non-quoted bit as
1083 # our summary
1084 summary = section
1086 if not summary:
1087 # if we don't have our summary yet use the first line of this
1088 # section
1089 summary = section
1090 elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1091 # lose any signature
1092 break
1093 elif original_msg.match(lines[0]):
1094 # ditch the stupid Outlook quoting of the entire original message
1095 break
1097 # and add the section to the output
1098 l.append(section)
1100 # figure the summary - find the first sentence-ending punctuation or the
1101 # first whole line, whichever is longest
1102 sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1103 if sentence:
1104 sentence = sentence.group(1)
1105 else:
1106 sentence = ''
1107 first = eol.split(summary)[0]
1108 summary = max(sentence, first)
1110 # Now reconstitute the message content minus the bits we don't care
1111 # about.
1112 if not keep_body:
1113 content = '\n\n'.join(l)
1115 return summary, content
1117 # vim: set filetype=python ts=4 sw=4 et si