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