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