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