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