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.138 2003-11-13 03:41:38 richard Exp $
77 """
79 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
80 import time, random, sys
81 import traceback, MimeWriter, rfc822
83 from roundup import hyperdb, date, password, rfc2822
84 from roundup.mailer import Mailer
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 def getparam(str, param):
117 ''' From the rfc822 "header" string, extract "param" if it appears.
118 '''
119 if ';' not in str:
120 return None
121 str = str[str.index(';'):]
122 while str[:1] == ';':
123 str = str[1:]
124 if ';' in str:
125 # XXX Should parse quotes!
126 end = str.index(';')
127 else:
128 end = len(str)
129 f = str[:end]
130 if '=' in f:
131 i = f.index('=')
132 if f[:i].strip().lower() == param:
133 return rfc822.unquote(f[i+1:].strip())
134 return None
136 class Message(mimetools.Message):
137 ''' subclass mimetools.Message so we can retrieve the parts of the
138 message...
139 '''
140 def getPart(self):
141 ''' Get a single part of a multipart message and return it as a new
142 Message instance.
143 '''
144 boundary = self.getparam('boundary')
145 mid, end = '--'+boundary, '--'+boundary+'--'
146 s = cStringIO.StringIO()
147 while 1:
148 line = self.fp.readline()
149 if not line:
150 break
151 if line.strip() in (mid, end):
152 break
153 s.write(line)
154 if not s.getvalue().strip():
155 return None
156 s.seek(0)
157 return Message(s)
159 def getheader(self, name, default=None):
160 hdr = mimetools.Message.getheader(self, name, default)
161 if hdr:
162 hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
163 return rfc2822.decode_header(hdr)
165 class MailGW:
167 # Matches subjects like:
168 # Re: "[issue1234] title of issue [status=resolved]"
169 subject_re = re.compile(r'''
170 (?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*\s* # Re:
171 (?P<quote>")? # Leading "
172 (\[(?P<classname>[^\d\s]+) # [issue..
173 (?P<nodeid>\d+)? # ..1234]
174 \])?\s*
175 (?P<title>[^[]+)? # issue title
176 "? # Trailing "
177 (\[(?P<args>.+?)\])? # [prop=value]
178 ''', re.IGNORECASE|re.VERBOSE)
180 def __init__(self, instance, db, arguments={}):
181 self.instance = instance
182 self.db = db
183 self.arguments = arguments
184 self.mailer = Mailer(instance.config)
186 # should we trap exceptions (normal usage) or pass them through
187 # (for testing)
188 self.trapExceptions = 1
190 def do_pipe(self):
191 """ Read a message from standard input and pass it to the mail handler.
193 Read into an internal structure that we can seek on (in case
194 there's an error).
196 XXX: we may want to read this into a temporary file instead...
197 """
198 s = cStringIO.StringIO()
199 s.write(sys.stdin.read())
200 s.seek(0)
201 self.main(s)
202 return 0
204 def do_mailbox(self, filename):
205 """ Read a series of messages from the specified unix mailbox file and
206 pass each to the mail handler.
207 """
208 # open the spool file and lock it
209 import fcntl
210 # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
211 if hasattr(fcntl, 'LOCK_EX'):
212 FCNTL = fcntl
213 else:
214 import FCNTL
215 f = open(filename, 'r+')
216 fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
218 # handle and clear the mailbox
219 try:
220 from mailbox import UnixMailbox
221 mailbox = UnixMailbox(f, factory=Message)
222 # grab one message
223 message = mailbox.next()
224 while message:
225 # handle this message
226 self.handle_Message(message)
227 message = mailbox.next()
228 # nuke the file contents
229 os.ftruncate(f.fileno(), 0)
230 except:
231 import traceback
232 traceback.print_exc()
233 return 1
234 fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
235 return 0
237 def do_apop(self, server, user='', password=''):
238 ''' Do authentication POP
239 '''
240 self.do_pop(server, user, password, apop=1)
242 def do_pop(self, server, user='', password='', apop=0):
243 '''Read a series of messages from the specified POP server.
244 '''
245 import getpass, poplib, socket
246 try:
247 if not user:
248 user = raw_input(_('User: '))
249 if not password:
250 password = getpass.getpass()
251 except (KeyboardInterrupt, EOFError):
252 # Ctrl C or D maybe also Ctrl Z under Windows.
253 print "\nAborted by user."
254 return 1
256 # open a connection to the server and retrieve all messages
257 try:
258 server = poplib.POP3(server)
259 except socket.error, message:
260 print "POP server error:", message
261 return 1
262 if apop:
263 server.apop(user, password)
264 else:
265 server.user(user)
266 server.pass_(password)
267 numMessages = len(server.list()[1])
268 for i in range(1, numMessages+1):
269 # retr: returns
270 # [ pop response e.g. '+OK 459 octets',
271 # [ array of message lines ],
272 # number of octets ]
273 lines = server.retr(i)[1]
274 s = cStringIO.StringIO('\n'.join(lines))
275 s.seek(0)
276 self.handle_Message(Message(s))
277 # delete the message
278 server.dele(i)
280 # quit the server to commit changes.
281 server.quit()
282 return 0
284 def main(self, fp):
285 ''' fp - the file from which to read the Message.
286 '''
287 return self.handle_Message(Message(fp))
289 def handle_Message(self, message):
290 """Handle an RFC822 Message
292 Handle the Message object by calling handle_message() and then cope
293 with any errors raised by handle_message.
294 This method's job is to make that call and handle any
295 errors in a sane manner. It should be replaced if you wish to
296 handle errors in a different manner.
297 """
298 # in some rare cases, a particularly stuffed-up e-mail will make
299 # its way into here... try to handle it gracefully
300 sendto = message.getaddrlist('resent-from')
301 if not sendto:
302 sendto = message.getaddrlist('from')
303 if sendto:
304 if not self.trapExceptions:
305 return self.handle_message(message)
306 try:
307 return self.handle_message(message)
308 except MailUsageHelp:
309 # bounce the message back to the sender with the usage message
310 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
311 sendto = [sendto[0][1]]
312 m = ['']
313 m.append('\n\nMail Gateway Help\n=================')
314 m.append(fulldoc)
315 self.mailer.bounce_message(message, sendto, m,
316 subject="Mail Gateway Help")
317 except MailUsageError, value:
318 # bounce the message back to the sender with the usage message
319 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
320 sendto = [sendto[0][1]]
321 m = ['']
322 m.append(str(value))
323 m.append('\n\nMail Gateway Help\n=================')
324 m.append(fulldoc)
325 self.mailer.bounce_message(message, sendto, m)
326 except Unauthorized, value:
327 # just inform the user that he is not authorized
328 sendto = [sendto[0][1]]
329 m = ['']
330 m.append(str(value))
331 self.mailer.bounce_message(message, sendto, m)
332 except MailLoop:
333 # XXX we should use a log file here...
334 return
335 except:
336 # bounce the message back to the sender with the error message
337 # XXX we should use a log file here...
338 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
339 m = ['']
340 m.append('An unexpected error occurred during the processing')
341 m.append('of your message. The tracker administrator is being')
342 m.append('notified.\n')
343 m.append('---- traceback of failure ----')
344 s = cStringIO.StringIO()
345 import traceback
346 traceback.print_exc(None, s)
347 m.append(s.getvalue())
348 self.mailer.bounce_message(message, sendto, m)
349 else:
350 # very bad-looking message - we don't even know who sent it
351 # XXX we should use a log file here...
352 sendto = [self.instance.config.ADMIN_EMAIL]
353 m = ['Subject: badly formed message from mail gateway']
354 m.append('')
355 m.append('The mail gateway retrieved a message which has no From:')
356 m.append('line, indicating that it is corrupt. Please check your')
357 m.append('mail gateway source. Failed message is attached.')
358 m.append('')
359 self.mailer.bounce_message(message, sendto, m,
360 subject='Badly formed message from mail gateway')
362 def get_part_data_decoded(self,part):
363 encoding = part.getencoding()
364 data = None
365 if encoding == 'base64':
366 # BUG: is base64 really used for text encoding or
367 # are we inserting zip files here.
368 data = binascii.a2b_base64(part.fp.read())
369 elif encoding == 'quoted-printable':
370 # the quopri module wants to work with files
371 decoded = cStringIO.StringIO()
372 quopri.decode(part.fp, decoded)
373 data = decoded.getvalue()
374 elif encoding == 'uuencoded':
375 data = binascii.a2b_uu(part.fp.read())
376 else:
377 # take it as text
378 data = part.fp.read()
380 # Encode message to unicode
381 charset = rfc2822.unaliasCharset(part.getparam("charset"))
382 if charset:
383 # Do conversion only if charset specified
384 edata = unicode(data, charset).encode('utf-8')
385 # Convert from dos eol to unix
386 edata = edata.replace('\r\n', '\n')
387 else:
388 # Leave message content as is
389 edata = data
391 return edata
393 def handle_message(self, message):
394 ''' message - a Message instance
396 Parse the message as per the module docstring.
397 '''
398 # detect loops
399 if message.getheader('x-roundup-loop', ''):
400 raise MailLoop
402 # XXX Don't enable. This doesn't work yet.
403 # "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
404 # handle delivery to addresses like:tracker+issue25@some.dom.ain
405 # use the embedded issue number as our issue
406 # if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
407 # self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
408 # issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
409 # for header in ['to', 'cc', 'bcc']:
410 # addresses = message.getheader(header, '')
411 # if addresses:
412 # # FIXME, this only finds the first match in the addresses.
413 # issue = re.search(issue_re, addresses, 'i')
414 # if issue:
415 # classname = issue.group('classname')
416 # nodeid = issue.group('nodeid')
417 # break
419 # determine the sender's address
420 from_list = message.getaddrlist('resent-from')
421 if not from_list:
422 from_list = message.getaddrlist('from')
424 # handle the subject line
425 subject = message.getheader('subject', '')
427 if not subject:
428 raise MailUsageError, '''
429 Emails to Roundup trackers must include a Subject: line!
430 '''
432 if subject.strip().lower() == 'help':
433 raise MailUsageHelp
435 m = self.subject_re.match(subject)
437 # check for well-formed subject line
438 if m:
439 # get the classname
440 classname = m.group('classname')
441 if classname is None:
442 # no classname, check if this a registration confirmation email
443 # or fallback on the default class
444 otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
445 otk = otk_re.search(m.group('title'))
446 if otk:
447 self.db.confirm_registration(otk.group('otk'))
448 subject = 'Your registration to %s is complete' % \
449 self.instance.config.TRACKER_NAME
450 sendto = [from_list[0][1]]
451 self.mailer.standard_message(sendto, subject, '')
452 return
453 elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
454 self.instance.config.MAIL_DEFAULT_CLASS:
455 classname = self.instance.config.MAIL_DEFAULT_CLASS
456 else:
457 # fail
458 m = None
460 if not m:
461 raise MailUsageError, """
462 The message you sent to roundup did not contain a properly formed subject
463 line. The subject must contain a class name or designator to indicate the
464 'topic' of the message. For example:
465 Subject: [issue] This is a new issue
466 - this will create a new issue in the tracker with the title 'This is
467 a new issue'.
468 Subject: [issue1234] This is a followup to issue 1234
469 - this will append the message's contents to the existing issue 1234
470 in the tracker.
472 Subject was: '%s'
473 """%subject
475 # get the class
476 try:
477 cl = self.db.getclass(classname)
478 except KeyError:
479 raise MailUsageError, '''
480 The class name you identified in the subject line ("%s") does not exist in the
481 database.
483 Valid class names are: %s
484 Subject was: "%s"
485 '''%(classname, ', '.join(self.db.getclasses()), subject)
487 # get the optional nodeid
488 nodeid = m.group('nodeid')
490 # title is optional too
491 title = m.group('title')
492 if title:
493 title = title.strip()
494 else:
495 title = ''
497 # strip off the quotes that dumb emailers put around the subject, like
498 # Re: "[issue1] bla blah"
499 if m.group('quote') and title.endswith('"'):
500 title = title[:-1]
502 # but we do need either a title or a nodeid...
503 if nodeid is None and not title:
504 raise MailUsageError, '''
505 I cannot match your message to a node in the database - you need to either
506 supply a full node identifier (with number, eg "[issue123]" or keep the
507 previous subject title intact so I can match that.
509 Subject was: "%s"
510 '''%subject
512 # If there's no nodeid, check to see if this is a followup and
513 # maybe someone's responded to the initial mail that created an
514 # entry. Try to find the matching nodes with the same title, and
515 # use the _last_ one matched (since that'll _usually_ be the most
516 # recent...)
517 if nodeid is None and m.group('refwd'):
518 l = cl.stringFind(title=title)
519 if l:
520 nodeid = l[-1]
522 # if a nodeid was specified, make sure it's valid
523 if nodeid is not None and not cl.hasnode(nodeid):
524 raise MailUsageError, '''
525 The node specified by the designator in the subject of your message ("%s")
526 does not exist.
528 Subject was: "%s"
529 '''%(nodeid, subject)
531 # Handle the arguments specified by the email gateway command line.
532 # We do this by looping over the list of self.arguments looking for
533 # a -C to tell us what class then the -S setting string.
534 msg_props = {}
535 user_props = {}
536 file_props = {}
537 issue_props = {}
538 # so, if we have any arguments, use them
539 if self.arguments:
540 current_class = 'msg'
541 for option, propstring in self.arguments:
542 if option in ( '-C', '--class'):
543 current_class = propstring.strip()
544 if current_class not in ('msg', 'file', 'user', 'issue'):
545 raise MailUsageError, '''
546 The mail gateway is not properly set up. Please contact
547 %s and have them fix the incorrect class specified as:
548 %s
549 '''%(self.instance.config.ADMIN_EMAIL, current_class)
550 if option in ('-S', '--set'):
551 if current_class == 'issue' :
552 errors, issue_props = setPropArrayFromString(self,
553 cl, propstring.strip(), nodeid)
554 elif current_class == 'file' :
555 temp_cl = self.db.getclass('file')
556 errors, file_props = setPropArrayFromString(self,
557 temp_cl, propstring.strip())
558 elif current_class == 'msg' :
559 temp_cl = self.db.getclass('msg')
560 errors, msg_props = setPropArrayFromString(self,
561 temp_cl, propstring.strip())
562 elif current_class == 'user' :
563 temp_cl = self.db.getclass('user')
564 errors, user_props = setPropArrayFromString(self,
565 temp_cl, propstring.strip())
566 if errors:
567 raise MailUsageError, '''
568 The mail gateway is not properly set up. Please contact
569 %s and have them fix the incorrect properties:
570 %s
571 '''%(self.instance.config.ADMIN_EMAIL, errors)
573 #
574 # handle the users
575 #
576 # Don't create users if anonymous isn't allowed to register
577 create = 1
578 anonid = self.db.user.lookup('anonymous')
579 if not self.db.security.hasPermission('Email Registration', anonid):
580 create = 0
582 # ok, now figure out who the author is - create a new user if the
583 # "create" flag is true
584 author = uidFromAddress(self.db, from_list[0], create=create)
586 # if we're not recognised, and we don't get added as a user, then we
587 # must be anonymous
588 if not author:
589 author = anonid
591 # make sure the author has permission to use the email interface
592 if not self.db.security.hasPermission('Email Access', author):
593 if author == anonid:
594 # we're anonymous and we need to be a registered user
595 raise Unauthorized, '''
596 You are not a registered user.
598 Unknown address: %s
599 '''%from_list[0][1]
600 else:
601 # we're registered and we're _still_ not allowed access
602 raise Unauthorized, 'You are not permitted to access '\
603 'this tracker.'
605 # make sure they're allowed to edit this class of information
606 if not self.db.security.hasPermission('Edit', author, classname):
607 raise Unauthorized, 'You are not permitted to edit %s.'%classname
609 # the author may have been created - make sure the change is
610 # committed before we reopen the database
611 self.db.commit()
613 # reopen the database as the author
614 username = self.db.user.get(author, 'username')
615 self.db.close()
616 self.db = self.instance.open(username)
618 # re-get the class with the new database connection
619 cl = self.db.getclass(classname)
621 # now update the recipients list
622 recipients = []
623 tracker_email = self.instance.config.TRACKER_EMAIL.lower()
624 for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
625 r = recipient[1].strip().lower()
626 if r == tracker_email or not r:
627 continue
629 # look up the recipient - create if necessary (and we're
630 # allowed to)
631 recipient = uidFromAddress(self.db, recipient, create, **user_props)
633 # if all's well, add the recipient to the list
634 if recipient:
635 recipients.append(recipient)
637 #
638 # handle the subject argument list
639 #
640 # figure what the properties of this Class are
641 properties = cl.getprops()
642 props = {}
643 args = m.group('args')
644 if args:
645 errors, props = setPropArrayFromString(self, cl, args, nodeid)
646 # handle any errors parsing the argument list
647 if errors:
648 errors = '\n- '.join(errors)
649 raise MailUsageError, '''
650 There were problems handling your subject line argument list:
651 - %s
653 Subject was: "%s"
654 '''%(errors, subject)
657 # set the issue title to the subject
658 if properties.has_key('title') and not issue_props.has_key('title'):
659 issue_props['title'] = title.strip()
661 #
662 # handle message-id and in-reply-to
663 #
664 messageid = message.getheader('message-id')
665 inreplyto = message.getheader('in-reply-to') or ''
666 # generate a messageid if there isn't one
667 if not messageid:
668 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
669 classname, nodeid, self.instance.config.MAIL_DOMAIN)
671 #
672 # now handle the body - find the message
673 #
674 content_type = message.gettype()
675 attachments = []
676 # General multipart handling:
677 # Take the first text/plain part, anything else is considered an
678 # attachment.
679 # multipart/mixed: multiple "unrelated" parts.
680 # multipart/signed (rfc 1847):
681 # The control information is carried in the second of the two
682 # required body parts.
683 # ACTION: Default, so if content is text/plain we get it.
684 # multipart/encrypted (rfc 1847):
685 # The control information is carried in the first of the two
686 # required body parts.
687 # ACTION: Not handleable as the content is encrypted.
688 # multipart/related (rfc 1872, 2112, 2387):
689 # The Multipart/Related content-type addresses the MIME
690 # representation of compound objects.
691 # ACTION: Default. If we are lucky there is a text/plain.
692 # TODO: One should use the start part and look for an Alternative
693 # that is text/plain.
694 # multipart/Alternative (rfc 1872, 1892):
695 # only in "related" ?
696 # multipart/report (rfc 1892):
697 # e.g. mail system delivery status reports.
698 # ACTION: Default. Could be ignored or used for Delivery Notification
699 # flagging.
700 # multipart/form-data:
701 # For web forms only.
702 if content_type == 'multipart/mixed':
703 # skip over the intro to the first boundary
704 part = message.getPart()
705 content = None
706 while 1:
707 # get the next part
708 part = message.getPart()
709 if part is None:
710 break
711 # parse it
712 subtype = part.gettype()
713 if subtype == 'text/plain' and not content:
714 # The first text/plain part is the message content.
715 content = self.get_part_data_decoded(part)
716 elif subtype == 'message/rfc822':
717 # handle message/rfc822 specially - the name should be
718 # the subject of the actual e-mail embedded here
719 i = part.fp.tell()
720 mailmess = Message(part.fp)
721 name = mailmess.getheader('subject')
722 part.fp.seek(i)
723 attachments.append((name, 'message/rfc822', part.fp.read()))
724 elif subtype == 'multipart/alternative':
725 # Search for text/plain in message with attachment and
726 # alternative text representation
727 # skip over intro to first boundary
728 part.getPart()
729 while 1:
730 # get the next part
731 subpart = part.getPart()
732 if subpart is None:
733 break
734 # parse it
735 if subpart.gettype() == 'text/plain' and not content:
736 content = self.get_part_data_decoded(subpart)
737 else:
738 # try name on Content-Type
739 name = part.getparam('name')
740 if name:
741 name = name.strip()
742 if not name:
743 disp = part.getheader('content-disposition', None)
744 if disp:
745 name = getparam(disp, 'filename')
746 if name:
747 name = name.strip()
748 # this is just an attachment
749 data = self.get_part_data_decoded(part)
750 attachments.append((name, part.gettype(), data))
751 if content is None:
752 raise MailUsageError, '''
753 Roundup requires the submission to be plain text. The message parser could
754 not find a text/plain part to use.
755 '''
757 elif content_type[:10] == 'multipart/':
758 # skip over the intro to the first boundary
759 message.getPart()
760 content = None
761 while 1:
762 # get the next part
763 part = message.getPart()
764 if part is None:
765 break
766 # parse it
767 if part.gettype() == 'text/plain' and not content:
768 content = self.get_part_data_decoded(part)
769 if content is None:
770 raise MailUsageError, '''
771 Roundup requires the submission to be plain text. The message parser could
772 not find a text/plain part to use.
773 '''
775 elif content_type != 'text/plain':
776 raise MailUsageError, '''
777 Roundup requires the submission to be plain text. The message parser could
778 not find a text/plain part to use.
779 '''
781 else:
782 content = self.get_part_data_decoded(message)
784 # figure how much we should muck around with the email body
785 keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
786 'no') == 'yes'
787 keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
788 'no') == 'yes'
790 # parse the body of the message, stripping out bits as appropriate
791 summary, content = parseContent(content, keep_citations,
792 keep_body)
793 content = content.strip()
795 #
796 # handle the attachments
797 #
798 if properties.has_key('files'):
799 files = []
800 for (name, mime_type, data) in attachments:
801 if not name:
802 name = "unnamed"
803 files.append(self.db.file.create(type=mime_type, name=name,
804 content=data, **file_props))
805 # attach the files to the issue
806 if nodeid:
807 # extend the existing files list
808 fileprop = cl.get(nodeid, 'files')
809 fileprop.extend(files)
810 props['files'] = fileprop
811 else:
812 # pre-load the files list
813 props['files'] = files
815 #
816 # create the message if there's a message body (content)
817 #
818 if (content and properties.has_key('messages')):
819 message_id = self.db.msg.create(author=author,
820 recipients=recipients, date=date.Date('.'), summary=summary,
821 content=content, files=files, messageid=messageid,
822 inreplyto=inreplyto, **msg_props)
824 # attach the message to the node
825 if nodeid:
826 # add the message to the node's list
827 messages = cl.get(nodeid, 'messages')
828 messages.append(message_id)
829 props['messages'] = messages
830 else:
831 # pre-load the messages list
832 props['messages'] = [message_id]
834 #
835 # perform the node change / create
836 #
837 try:
838 # merge the command line props defined in issue_props into
839 # the props dictionary because function(**props, **issue_props)
840 # is a syntax error.
841 for prop in issue_props.keys() :
842 if not props.has_key(prop) :
843 props[prop] = issue_props[prop]
844 if nodeid:
845 cl.set(nodeid, **props)
846 else:
847 nodeid = cl.create(**props)
848 except (TypeError, IndexError, ValueError), message:
849 raise MailUsageError, '''
850 There was a problem with the message you sent:
851 %s
852 '''%message
854 # commit the changes to the DB
855 self.db.commit()
857 return nodeid
860 def setPropArrayFromString(self, cl, propString, nodeid=None):
861 ''' takes string of form prop=value,value;prop2=value
862 and returns (error, prop[..])
863 '''
864 props = {}
865 errors = []
866 for prop in string.split(propString, ';'):
867 # extract the property name and value
868 try:
869 propname, value = prop.split('=')
870 except ValueError, message:
871 errors.append('not of form [arg=value,value,...;'
872 'arg=value,value,...]')
873 return (errors, props)
874 # convert the value to a hyperdb-usable value
875 propname = propname.strip()
876 try:
877 props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
878 propname, value)
879 except hyperdb.HyperdbValueError, message:
880 errors.append(message)
881 return errors, props
884 def extractUserFromList(userClass, users):
885 '''Given a list of users, try to extract the first non-anonymous user
886 and return that user, otherwise return None
887 '''
888 if len(users) > 1:
889 for user in users:
890 # make sure we don't match the anonymous or admin user
891 if userClass.get(user, 'username') in ('admin', 'anonymous'):
892 continue
893 # first valid match will do
894 return user
895 # well, I guess we have no choice
896 return user[0]
897 elif users:
898 return users[0]
899 return None
902 def uidFromAddress(db, address, create=1, **user_props):
903 ''' address is from the rfc822 module, and therefore is (name, addr)
905 user is created if they don't exist in the db already
906 user_props may supply additional user information
907 '''
908 (realname, address) = address
910 # try a straight match of the address
911 user = extractUserFromList(db.user, db.user.stringFind(address=address))
912 if user is not None:
913 return user
915 # try the user alternate addresses if possible
916 props = db.user.getprops()
917 if props.has_key('alternate_addresses'):
918 users = db.user.filter(None, {'alternate_addresses': address})
919 user = extractUserFromList(db.user, users)
920 if user is not None:
921 return user
923 # try to match the username to the address (for local
924 # submissions where the address is empty)
925 user = extractUserFromList(db.user, db.user.stringFind(username=address))
927 # couldn't match address or username, so create a new user
928 if create:
929 # generate a username
930 if '@' in address:
931 username = address.split('@')[0]
932 else:
933 username = address
934 trying = username
935 n = 0
936 while 1:
937 try:
938 # does this username exist already?
939 db.user.lookup(trying)
940 except KeyError:
941 break
942 n += 1
943 trying = username + str(n)
945 # create!
946 return db.user.create(username=trying, address=address,
947 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
948 password=password.Password(password.generatePassword()),
949 **user_props)
950 else:
951 return 0
954 def parseContent(content, keep_citations, keep_body,
955 blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
956 eol=re.compile(r'[\r\n]+'),
957 signature=re.compile(r'^[>|\s]*-- ?$'),
958 original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
959 ''' The message body is divided into sections by blank lines.
960 Sections where the second and all subsequent lines begin with a ">"
961 or "|" character are considered "quoting sections". The first line of
962 the first non-quoting section becomes the summary of the message.
964 If keep_citations is true, then we keep the "quoting sections" in the
965 content.
966 If keep_body is true, we even keep the signature sections.
967 '''
968 # strip off leading carriage-returns / newlines
969 i = 0
970 for i in range(len(content)):
971 if content[i] not in '\r\n':
972 break
973 if i > 0:
974 sections = blank_line.split(content[i:])
975 else:
976 sections = blank_line.split(content)
978 # extract out the summary from the message
979 summary = ''
980 l = []
981 for section in sections:
982 #section = section.strip()
983 if not section:
984 continue
985 lines = eol.split(section)
986 if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
987 lines[1] and lines[1][0] in '>|'):
988 # see if there's a response somewhere inside this section (ie.
989 # no blank line between quoted message and response)
990 for line in lines[1:]:
991 if line and line[0] not in '>|':
992 break
993 else:
994 # we keep quoted bits if specified in the config
995 if keep_citations:
996 l.append(section)
997 continue
998 # keep this section - it has reponse stuff in it
999 lines = lines[lines.index(line):]
1000 section = '\n'.join(lines)
1001 # and while we're at it, use the first non-quoted bit as
1002 # our summary
1003 summary = section
1005 if not summary:
1006 # if we don't have our summary yet use the first line of this
1007 # section
1008 summary = section
1009 elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1010 # lose any signature
1011 break
1012 elif original_msg.match(lines[0]):
1013 # ditch the stupid Outlook quoting of the entire original message
1014 break
1016 # and add the section to the output
1017 l.append(section)
1019 # figure the summary - find the first sentence-ending punctuation or the
1020 # first whole line, whichever is longest
1021 sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1022 if sentence:
1023 sentence = sentence.group(1)
1024 else:
1025 sentence = ''
1026 first = eol.split(summary)[0]
1027 summary = max(sentence, first)
1029 # Now reconstitute the message content minus the bits we don't care
1030 # about.
1031 if not keep_body:
1032 content = '\n\n'.join(l)
1034 return summary, content
1036 # vim: set filetype=python ts=4 sw=4 et si