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.95 2002-10-07 00:52:51 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 Unauthorized(Exception):
96 """ Access denied """
98 def initialiseSecurity(security):
99 ''' Create some Permissions and Roles on the security object
101 This function is directly invoked by security.Security.__init__()
102 as a part of the Security object instantiation.
103 '''
104 security.addPermission(name="Email Registration",
105 description="Anonymous may register through e-mail")
106 p = security.addPermission(name="Email Access",
107 description="User may use the email interface")
108 security.addPermissionToRole('Admin', p)
110 class Message(mimetools.Message):
111 ''' subclass mimetools.Message so we can retrieve the parts of the
112 message...
113 '''
114 def getPart(self):
115 ''' Get a single part of a multipart message and return it as a new
116 Message instance.
117 '''
118 boundary = self.getparam('boundary')
119 mid, end = '--'+boundary, '--'+boundary+'--'
120 s = cStringIO.StringIO()
121 while 1:
122 line = self.fp.readline()
123 if not line:
124 break
125 if line.strip() in (mid, end):
126 break
127 s.write(line)
128 if not s.getvalue().strip():
129 return None
130 s.seek(0)
131 return Message(s)
133 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re|aw)\s*\W?\s*)*'
134 r'\s*(?P<quote>")?(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
135 r'\s*(?P<title>[^[]+)?"?(\[(?P<args>.+?)\])?', re.I)
137 class MailGW:
138 def __init__(self, instance, db):
139 self.instance = instance
140 self.db = db
142 # should we trap exceptions (normal usage) or pass them through
143 # (for testing)
144 self.trapExceptions = 1
146 def do_pipe(self):
147 ''' Read a message from standard input and pass it to the mail handler.
148 '''
149 self.main(sys.stdin)
150 return 0
152 def do_mailbox(self, filename):
153 ''' Read a series of messages from the specified unix mailbox file and
154 pass each to the mail handler.
155 '''
156 # open the spool file and lock it
157 import fcntl, FCNTL
158 f = open(filename, 'r+')
159 fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
161 # handle and clear the mailbox
162 try:
163 from mailbox import UnixMailbox
164 mailbox = UnixMailbox(f, factory=Message)
165 # grab one message
166 message = mailbox.next()
167 while message:
168 # handle this message
169 self.handle_Message(message)
170 message = mailbox.next()
171 # nuke the file contents
172 os.ftruncate(f.fileno(), 0)
173 except:
174 import traceback
175 traceback.print_exc()
176 return 1
177 fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
178 return 0
180 def do_pop(self, server, user='', password=''):
181 '''Read a series of messages from the specified POP server.
182 '''
183 import getpass, poplib, socket
184 try:
185 if not user:
186 user = raw_input(_('User: '))
187 if not password:
188 password = getpass.getpass()
189 except (KeyboardInterrupt, EOFError):
190 # Ctrl C or D maybe also Ctrl Z under Windows.
191 print "\nAborted by user."
192 return 1
194 # open a connection to the server and retrieve all messages
195 try:
196 server = poplib.POP3(server)
197 except socket.error, message:
198 print "POP server error:", message
199 return 1
200 server.user(user)
201 server.pass_(password)
202 numMessages = len(server.list()[1])
203 for i in range(1, numMessages+1):
204 # retr: returns
205 # [ pop response e.g. '+OK 459 octets',
206 # [ array of message lines ],
207 # number of octets ]
208 lines = server.retr(i)[1]
209 s = cStringIO.StringIO('\n'.join(lines))
210 s.seek(0)
211 self.handle_Message(Message(s))
212 # delete the message
213 server.dele(i)
215 # quit the server to commit changes.
216 server.quit()
217 return 0
219 def main(self, fp):
220 ''' fp - the file from which to read the Message.
221 '''
222 return self.handle_Message(Message(fp))
224 def handle_Message(self, message):
225 '''Handle an RFC822 Message
227 Handle the Message object by calling handle_message() and then cope
228 with any errors raised by handle_message.
229 This method's job is to make that call and handle any
230 errors in a sane manner. It should be replaced if you wish to
231 handle errors in a different manner.
232 '''
233 # in some rare cases, a particularly stuffed-up e-mail will make
234 # its way into here... try to handle it gracefully
235 sendto = message.getaddrlist('from')
236 if sendto:
237 if not self.trapExceptions:
238 return self.handle_message(message)
239 try:
240 return self.handle_message(message)
241 except MailUsageHelp:
242 # bounce the message back to the sender with the usage message
243 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
244 sendto = [sendto[0][1]]
245 m = ['']
246 m.append('\n\nMail Gateway Help\n=================')
247 m.append(fulldoc)
248 m = self.bounce_message(message, sendto, m,
249 subject="Mail Gateway Help")
250 except MailUsageError, value:
251 # bounce the message back to the sender with the usage message
252 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
253 sendto = [sendto[0][1]]
254 m = ['']
255 m.append(str(value))
256 m.append('\n\nMail Gateway Help\n=================')
257 m.append(fulldoc)
258 m = self.bounce_message(message, sendto, m)
259 except Unauthorized, value:
260 # just inform the user that he is not authorized
261 sendto = [sendto[0][1]]
262 m = ['']
263 m.append(str(value))
264 m = self.bounce_message(message, sendto, m)
265 except:
266 # bounce the message back to the sender with the error message
267 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
268 m = ['']
269 m.append('An unexpected error occurred during the processing')
270 m.append('of your message. The tracker administrator is being')
271 m.append('notified.\n')
272 m.append('---- traceback of failure ----')
273 s = cStringIO.StringIO()
274 import traceback
275 traceback.print_exc(None, s)
276 m.append(s.getvalue())
277 m = self.bounce_message(message, sendto, m)
278 else:
279 # very bad-looking message - we don't even know who sent it
280 sendto = [self.instance.config.ADMIN_EMAIL]
281 m = ['Subject: badly formed message from mail gateway']
282 m.append('')
283 m.append('The mail gateway retrieved a message which has no From:')
284 m.append('line, indicating that it is corrupt. Please check your')
285 m.append('mail gateway source. Failed message is attached.')
286 m.append('')
287 m = self.bounce_message(message, sendto, m,
288 subject='Badly formed message from mail gateway')
290 # now send the message
291 if SENDMAILDEBUG:
292 open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%(
293 self.instance.config.ADMIN_EMAIL, ', '.join(sendto),
294 m.getvalue()))
295 else:
296 try:
297 smtp = smtplib.SMTP(self.instance.config.MAILHOST)
298 smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto,
299 m.getvalue())
300 except socket.error, value:
301 raise MailGWError, "Couldn't send error email: "\
302 "mailhost %s"%value
303 except smtplib.SMTPException, value:
304 raise MailGWError, "Couldn't send error email: %s"%value
306 def bounce_message(self, message, sendto, error,
307 subject='Failed issue tracker submission'):
308 ''' create a message that explains the reason for the failed
309 issue submission to the author and attach the original
310 message.
311 '''
312 msg = cStringIO.StringIO()
313 writer = MimeWriter.MimeWriter(msg)
314 writer.addheader('Subject', subject)
315 writer.addheader('From', '%s <%s>'% (self.instance.config.TRACKER_NAME,
316 self.instance.config.TRACKER_EMAIL))
317 writer.addheader('To', ','.join(sendto))
318 writer.addheader('MIME-Version', '1.0')
319 part = writer.startmultipartbody('mixed')
320 part = writer.nextpart()
321 body = part.startbody('text/plain')
322 body.write('\n'.join(error))
324 # reconstruct the original message
325 m = cStringIO.StringIO()
326 w = MimeWriter.MimeWriter(m)
327 # default the content_type, just in case...
328 content_type = 'text/plain'
329 # add the headers except the content-type
330 for header in message.headers:
331 header_name = header.split(':')[0]
332 if header_name.lower() == 'content-type':
333 content_type = message.getheader(header_name)
334 elif message.getheader(header_name):
335 w.addheader(header_name, message.getheader(header_name))
336 # now attach the message body
337 body = w.startbody(content_type)
338 try:
339 message.rewindbody()
340 except IOError:
341 body.write("*** couldn't include message body: read from pipe ***")
342 else:
343 body.write(message.fp.read())
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 part.addheader('Content-Transfer-Encoding', '7bit')
350 body = part.startbody('message/rfc822')
351 body.write(m.getvalue())
353 writer.lastpart()
354 return msg
356 def get_part_data_decoded(self,part):
357 encoding = part.getencoding()
358 data = None
359 if encoding == 'base64':
360 # BUG: is base64 really used for text encoding or
361 # are we inserting zip files here.
362 data = binascii.a2b_base64(part.fp.read())
363 elif encoding == 'quoted-printable':
364 # the quopri module wants to work with files
365 decoded = cStringIO.StringIO()
366 quopri.decode(part.fp, decoded)
367 data = decoded.getvalue()
368 elif encoding == 'uuencoded':
369 data = binascii.a2b_uu(part.fp.read())
370 else:
371 # take it as text
372 data = part.fp.read()
373 return data
375 def handle_message(self, message):
376 ''' message - a Message instance
378 Parse the message as per the module docstring.
379 '''
380 # handle the subject line
381 subject = message.getheader('subject', '')
383 if subject.strip() == 'help':
384 raise MailUsageHelp
386 m = subject_re.match(subject)
388 # check for well-formed subject line
389 if m:
390 # get the classname
391 classname = m.group('classname')
392 if classname is None:
393 # no classname, fallback on the default
394 if hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
395 self.instance.config.MAIL_DEFAULT_CLASS:
396 classname = self.instance.config.MAIL_DEFAULT_CLASS
397 else:
398 # fail
399 m = None
401 if not m:
402 raise MailUsageError, '''
403 The message you sent to roundup did not contain a properly formed subject
404 line. The subject must contain a class name or designator to indicate the
405 "topic" of the message. For example:
406 Subject: [issue] This is a new issue
407 - this will create a new issue in the tracker with the title "This is
408 a new issue".
409 Subject: [issue1234] This is a followup to issue 1234
410 - this will append the message's contents to the existing issue 1234
411 in the tracker.
413 Subject was: "%s"
414 '''%subject
416 # get the class
417 try:
418 cl = self.db.getclass(classname)
419 except KeyError:
420 raise MailUsageError, '''
421 The class name you identified in the subject line ("%s") does not exist in the
422 database.
424 Valid class names are: %s
425 Subject was: "%s"
426 '''%(classname, ', '.join(self.db.getclasses()), subject)
428 # get the optional nodeid
429 nodeid = m.group('nodeid')
431 # title is optional too
432 title = m.group('title')
433 if title:
434 title = title.strip()
435 else:
436 title = ''
438 # strip off the quotes that dumb emailers put around the subject, like
439 # Re: "[issue1] bla blah"
440 if m.group('quote') and title.endswith('"'):
441 title = title[:-1]
443 # but we do need either a title or a nodeid...
444 if nodeid is None and not title:
445 raise MailUsageError, '''
446 I cannot match your message to a node in the database - you need to either
447 supply a full node identifier (with number, eg "[issue123]" or keep the
448 previous subject title intact so I can match that.
450 Subject was: "%s"
451 '''%subject
453 # If there's no nodeid, check to see if this is a followup and
454 # maybe someone's responded to the initial mail that created an
455 # entry. Try to find the matching nodes with the same title, and
456 # use the _last_ one matched (since that'll _usually_ be the most
457 # recent...)
458 if nodeid is None and m.group('refwd'):
459 l = cl.stringFind(title=title)
460 if l:
461 nodeid = l[-1]
463 # if a nodeid was specified, make sure it's valid
464 if nodeid is not None and not cl.hasnode(nodeid):
465 raise MailUsageError, '''
466 The node specified by the designator in the subject of your message ("%s")
467 does not exist.
469 Subject was: "%s"
470 '''%(nodeid, subject)
472 #
473 # handle the users
474 #
475 # Don't create users if anonymous isn't allowed to register
476 create = 1
477 anonid = self.db.user.lookup('anonymous')
478 if not self.db.security.hasPermission('Email Registration', anonid):
479 create = 0
481 # ok, now figure out who the author is - create a new user if the
482 # "create" flag is true
483 author = uidFromAddress(self.db, message.getaddrlist('from')[0],
484 create=create)
486 # if we're not recognised, and we don't get added as a user, then we
487 # must be anonymous
488 if not author:
489 author = anonid
491 # make sure the author has permission to use the email interface
492 if not self.db.security.hasPermission('Email Access', author):
493 if author == anonid:
494 # we're anonymous and we need to be a registered user
495 raise Unauthorized, '''
496 You are not a registered user.
498 Unknown address: %s
499 '''%message.getaddrlist('from')[0][1]
500 else:
501 # we're registered and we're _still_ not allowed access
502 raise Unauthorized, 'You are not permitted to access '\
503 'this tracker.'
505 # make sure they're allowed to edit this class of information
506 if not self.db.security.hasPermission('Edit', author, classname):
507 raise Unauthorized, 'You are not permitted to edit %s.'%classname
509 # the author may have been created - make sure the change is
510 # committed before we reopen the database
511 self.db.commit()
513 # reopen the database as the author
514 username = self.db.user.get(author, 'username')
515 self.db.close()
516 self.db = self.instance.open(username)
518 # re-get the class with the new database connection
519 cl = self.db.getclass(classname)
521 # now update the recipients list
522 recipients = []
523 tracker_email = self.instance.config.TRACKER_EMAIL.lower()
524 for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
525 r = recipient[1].strip().lower()
526 if r == tracker_email or not r:
527 continue
529 # look up the recipient - create if necessary (and we're
530 # allowed to)
531 recipient = uidFromAddress(self.db, recipient, create)
533 # if all's well, add the recipient to the list
534 if recipient:
535 recipients.append(recipient)
537 #
538 # extract the args
539 #
540 subject_args = m.group('args')
542 #
543 # handle the subject argument list
544 #
545 # figure what the properties of this Class are
546 properties = cl.getprops()
547 props = {}
548 args = m.group('args')
549 if args:
550 errors = []
551 for prop in string.split(args, ';'):
552 # extract the property name and value
553 try:
554 propname, value = prop.split('=')
555 except ValueError, message:
556 errors.append('not of form [arg=value,'
557 'value,...;arg=value,value...]')
558 break
560 # ensure it's a valid property name
561 propname = propname.strip()
562 try:
563 proptype = properties[propname]
564 except KeyError:
565 errors.append('refers to an invalid property: '
566 '"%s"'%propname)
567 continue
569 # convert the string value to a real property value
570 if isinstance(proptype, hyperdb.String):
571 props[propname] = value.strip()
572 if isinstance(proptype, hyperdb.Password):
573 props[propname] = password.Password(value.strip())
574 elif isinstance(proptype, hyperdb.Date):
575 try:
576 props[propname] = date.Date(value.strip())
577 except ValueError, message:
578 errors.append('contains an invalid date for '
579 '%s.'%propname)
580 elif isinstance(proptype, hyperdb.Interval):
581 try:
582 props[propname] = date.Interval(value)
583 except ValueError, message:
584 errors.append('contains an invalid date interval'
585 'for %s.'%propname)
586 elif isinstance(proptype, hyperdb.Link):
587 linkcl = self.db.classes[proptype.classname]
588 propkey = linkcl.labelprop(default_to_id=1)
589 try:
590 props[propname] = linkcl.lookup(value)
591 except KeyError, message:
592 errors.append('"%s" is not a value for %s.'%(value,
593 propname))
594 elif isinstance(proptype, hyperdb.Multilink):
595 # get the linked class
596 linkcl = self.db.classes[proptype.classname]
597 propkey = linkcl.labelprop(default_to_id=1)
598 if nodeid:
599 curvalue = cl.get(nodeid, propname)
600 else:
601 curvalue = []
603 # handle each add/remove in turn
604 # keep an extra list for all items that are
605 # definitely in the new list (in case of e.g.
606 # <propname>=A,+B, which should replace the old
607 # list with A,B)
608 set = 0
609 newvalue = []
610 for item in value.split(','):
611 item = item.strip()
613 # handle +/-
614 remove = 0
615 if item.startswith('-'):
616 remove = 1
617 item = item[1:]
618 elif item.startswith('+'):
619 item = item[1:]
620 else:
621 set = 1
623 # look up the value
624 try:
625 item = linkcl.lookup(item)
626 except KeyError, message:
627 errors.append('"%s" is not a value for %s.'%(item,
628 propname))
629 continue
631 # perform the add/remove
632 if remove:
633 try:
634 curvalue.remove(item)
635 except ValueError:
636 errors.append('"%s" is not currently in '
637 'for %s.'%(item, propname))
638 continue
639 else:
640 newvalue.append(item)
641 if item not in curvalue:
642 curvalue.append(item)
644 # that's it, set the new Multilink property value,
645 # or overwrite it completely
646 if set:
647 props[propname] = newvalue
648 else:
649 props[propname] = curvalue
650 elif isinstance(proptype, hyperdb.Boolean):
651 value = value.strip()
652 props[propname] = value.lower() in ('yes', 'true', 'on', '1')
653 elif isinstance(proptype, hyperdb.Number):
654 value = value.strip()
655 props[propname] = int(value)
657 # handle any errors parsing the argument list
658 if errors:
659 errors = '\n- '.join(errors)
660 raise MailUsageError, '''
661 There were problems handling your subject line argument list:
662 - %s
664 Subject was: "%s"
665 '''%(errors, subject)
667 #
668 # handle message-id and in-reply-to
669 #
670 messageid = message.getheader('message-id')
671 inreplyto = message.getheader('in-reply-to') or ''
672 # generate a messageid if there isn't one
673 if not messageid:
674 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
675 classname, nodeid, self.instance.config.MAIL_DOMAIN)
677 #
678 # now handle the body - find the message
679 #
680 content_type = message.gettype()
681 attachments = []
682 # General multipart handling:
683 # Take the first text/plain part, anything else is considered an
684 # attachment.
685 # multipart/mixed: multiple "unrelated" parts.
686 # multipart/signed (rfc 1847):
687 # The control information is carried in the second of the two
688 # required body parts.
689 # ACTION: Default, so if content is text/plain we get it.
690 # multipart/encrypted (rfc 1847):
691 # The control information is carried in the first of the two
692 # required body parts.
693 # ACTION: Not handleable as the content is encrypted.
694 # multipart/related (rfc 1872, 2112, 2387):
695 # The Multipart/Related content-type addresses the MIME
696 # representation of compound objects.
697 # ACTION: Default. If we are lucky there is a text/plain.
698 # TODO: One should use the start part and look for an Alternative
699 # that is text/plain.
700 # multipart/Alternative (rfc 1872, 1892):
701 # only in "related" ?
702 # multipart/report (rfc 1892):
703 # e.g. mail system delivery status reports.
704 # ACTION: Default. Could be ignored or used for Delivery Notification
705 # flagging.
706 # multipart/form-data:
707 # For web forms only.
708 if content_type == 'multipart/mixed':
709 # skip over the intro to the first boundary
710 part = message.getPart()
711 content = None
712 while 1:
713 # get the next part
714 part = message.getPart()
715 if part is None:
716 break
717 # parse it
718 subtype = part.gettype()
719 if subtype == 'text/plain' and not content:
720 # The first text/plain part is the message content.
721 content = self.get_part_data_decoded(part)
722 elif subtype == 'message/rfc822':
723 # handle message/rfc822 specially - the name should be
724 # the subject of the actual e-mail embedded here
725 i = part.fp.tell()
726 mailmess = Message(part.fp)
727 name = mailmess.getheader('subject')
728 part.fp.seek(i)
729 attachments.append((name, 'message/rfc822', part.fp.read()))
730 else:
731 # try name on Content-Type
732 name = part.getparam('name')
733 # this is just an attachment
734 data = self.get_part_data_decoded(part)
735 attachments.append((name, part.gettype(), data))
736 if content is None:
737 raise MailUsageError, '''
738 Roundup requires the submission to be plain text. The message parser could
739 not find a text/plain part to use.
740 '''
742 elif content_type[:10] == 'multipart/':
743 # skip over the intro to the first boundary
744 message.getPart()
745 content = None
746 while 1:
747 # get the next part
748 part = message.getPart()
749 if part is None:
750 break
751 # parse it
752 if part.gettype() == 'text/plain' and not content:
753 content = self.get_part_data_decoded(part)
754 if content is None:
755 raise MailUsageError, '''
756 Roundup requires the submission to be plain text. The message parser could
757 not find a text/plain part to use.
758 '''
760 elif content_type != 'text/plain':
761 raise MailUsageError, '''
762 Roundup requires the submission to be plain text. The message parser could
763 not find a text/plain part to use.
764 '''
766 else:
767 content = self.get_part_data_decoded(message)
769 # figure how much we should muck around with the email body
770 keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT',
771 'no') == 'yes'
772 keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED',
773 'no') == 'yes'
775 # parse the body of the message, stripping out bits as appropriate
776 summary, content = parseContent(content, keep_citations,
777 keep_body)
779 #
780 # handle the attachments
781 #
782 files = []
783 for (name, mime_type, data) in attachments:
784 if not name:
785 name = "unnamed"
786 files.append(self.db.file.create(type=mime_type, name=name,
787 content=data))
789 #
790 # create the message if there's a message body (content)
791 #
792 if content:
793 message_id = self.db.msg.create(author=author,
794 recipients=recipients, date=date.Date('.'), summary=summary,
795 content=content, files=files, messageid=messageid,
796 inreplyto=inreplyto)
798 # attach the message to the node
799 if nodeid:
800 # add the message to the node's list
801 messages = cl.get(nodeid, 'messages')
802 messages.append(message_id)
803 props['messages'] = messages
804 else:
805 # pre-load the messages list
806 props['messages'] = [message_id]
808 # set the title to the subject
809 if properties.has_key('title') and not props.has_key('title'):
810 props['title'] = title
812 #
813 # perform the node change / create
814 #
815 try:
816 if nodeid:
817 cl.set(nodeid, **props)
818 else:
819 nodeid = cl.create(**props)
820 except (TypeError, IndexError, ValueError), message:
821 raise MailUsageError, '''
822 There was a problem with the message you sent:
823 %s
824 '''%message
826 # commit the changes to the DB
827 self.db.commit()
829 return nodeid
831 def extractUserFromList(userClass, users):
832 '''Given a list of users, try to extract the first non-anonymous user
833 and return that user, otherwise return None
834 '''
835 if len(users) > 1:
836 for user in users:
837 # make sure we don't match the anonymous or admin user
838 if userClass.get(user, 'username') in ('admin', 'anonymous'):
839 continue
840 # first valid match will do
841 return user
842 # well, I guess we have no choice
843 return user[0]
844 elif users:
845 return users[0]
846 return None
848 def uidFromAddress(db, address, create=1):
849 ''' address is from the rfc822 module, and therefore is (name, addr)
851 user is created if they don't exist in the db already
852 '''
853 (realname, address) = address
855 # try a straight match of the address
856 user = extractUserFromList(db.user, db.user.stringFind(address=address))
857 if user is not None: return user
859 # try the user alternate addresses if possible
860 props = db.user.getprops()
861 if props.has_key('alternate_addresses'):
862 users = db.user.filter(None, {'alternate_addresses': address})
863 user = extractUserFromList(db.user, users)
864 if user is not None: return user
866 # try to match the username to the address (for local
867 # submissions where the address is empty)
868 user = extractUserFromList(db.user, db.user.stringFind(username=address))
870 # couldn't match address or username, so create a new user
871 if create:
872 return db.user.create(username=address, address=address,
873 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES)
874 else:
875 return 0
877 def parseContent(content, keep_citations, keep_body,
878 blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
879 eol=re.compile(r'[\r\n]+'),
880 signature=re.compile(r'^[>|\s]*[-_]+\s*$'),
881 original_message=re.compile(r'^[>|\s]*-----Original Message-----$')):
882 ''' The message body is divided into sections by blank lines.
883 Sections where the second and all subsequent lines begin with a ">"
884 or "|" character are considered "quoting sections". The first line of
885 the first non-quoting section becomes the summary of the message.
887 If keep_citations is true, then we keep the "quoting sections" in the
888 content.
889 If keep_body is true, we even keep the signature sections.
890 '''
891 # strip off leading carriage-returns / newlines
892 i = 0
893 for i in range(len(content)):
894 if content[i] not in '\r\n':
895 break
896 if i > 0:
897 sections = blank_line.split(content[i:])
898 else:
899 sections = blank_line.split(content)
901 # extract out the summary from the message
902 summary = ''
903 l = []
904 for section in sections:
905 #section = section.strip()
906 if not section:
907 continue
908 lines = eol.split(section)
909 if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
910 lines[1] and lines[1][0] in '>|'):
911 # see if there's a response somewhere inside this section (ie.
912 # no blank line between quoted message and response)
913 for line in lines[1:]:
914 if line and line[0] not in '>|':
915 break
916 else:
917 # we keep quoted bits if specified in the config
918 if keep_citations:
919 l.append(section)
920 continue
921 # keep this section - it has reponse stuff in it
922 if not summary:
923 # and while we're at it, use the first non-quoted bit as
924 # our summary
925 summary = line
926 lines = lines[lines.index(line):]
927 section = '\n'.join(lines)
929 if not summary:
930 # if we don't have our summary yet use the first line of this
931 # section
932 summary = lines[0]
933 elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
934 # lose any signature
935 break
936 elif original_message.match(lines[0]):
937 # ditch the stupid Outlook quoting of the entire original message
938 break
940 # and add the section to the output
941 l.append(section)
943 # Now reconstitute the message content minus the bits we don't care
944 # about.
945 if not keep_body:
946 content = '\n\n'.join(l)
948 return summary, content
950 # vim: set filetype=python ts=4 sw=4 et si