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