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 #
18 # vim: ts=4 sw=4 expandtab
19 #
21 """An e-mail gateway for Roundup.
23 Incoming messages are examined for multiple parts:
24 . In a multipart/mixed message or part, each subpart is extracted and
25 examined. The text/plain subparts are assembled to form the textual
26 body of the message, to be stored in the file associated with a "msg"
27 class node. Any parts of other types are each stored in separate files
28 and given "file" class nodes that are linked to the "msg" node.
29 . In a multipart/alternative message or part, we look for a text/plain
30 subpart and ignore the other parts.
32 Summary
33 -------
34 The "summary" property on message nodes is taken from the first non-quoting
35 section in the message body. The message body is divided into sections by
36 blank lines. Sections where the second and all subsequent lines begin with
37 a ">" or "|" character are considered "quoting sections". The first line of
38 the first non-quoting section becomes the summary of the message.
40 Addresses
41 ---------
42 All of the addresses in the To: and Cc: headers of the incoming message are
43 looked up among the user nodes, and the corresponding users are placed in
44 the "recipients" property on the new "msg" node. The address in the From:
45 header similarly determines the "author" property of the new "msg"
46 node. The default handling for addresses that don't have corresponding
47 users is to create new users with no passwords and a username equal to the
48 address. (The web interface does not permit logins for users with no
49 passwords.) If we prefer to reject mail from outside sources, we can simply
50 register an auditor on the "user" class that prevents the creation of user
51 nodes with no passwords.
53 Actions
54 -------
55 The subject line of the incoming message is examined to determine whether
56 the message is an attempt to create a new item or to discuss an existing
57 item. A designator enclosed in square brackets is sought as the first thing
58 on the subject line (after skipping any "Fwd:" or "Re:" prefixes).
60 If an item designator (class name and id number) is found there, the newly
61 created "msg" node is added to the "messages" property for that item, and
62 any new "file" nodes are added to the "files" property for the item.
64 If just an item class name is found there, we attempt to create a new item
65 of that class with its "messages" property initialized to contain the new
66 "msg" node and its "files" property initialized to contain any new "file"
67 nodes.
69 Triggers
70 --------
71 Both cases may trigger detectors (in the first case we are calling the
72 set() method to add the message to the item's spool; in the second case we
73 are calling the create() method to create a new node). If an auditor raises
74 an exception, the original message is bounced back to the sender with the
75 explanatory message given in the exception.
77 $Id: mailgw.py,v 1.148 2004-04-13 04:16:36 richard Exp $
78 """
79 __docformat__ = 'restructuredtext'
81 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
82 import time, random, sys
83 import traceback, MimeWriter, rfc822
85 from roundup import hyperdb, date, password, rfc2822, exceptions
86 from roundup.mailer import Mailer
88 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
90 class MailGWError(ValueError):
91 pass
93 class MailUsageError(ValueError):
94 pass
96 class MailUsageHelp(Exception):
97 """ We need to send the help message to the user. """
98 pass
100 class Unauthorized(Exception):
101 """ Access denied """
102 pass
104 class IgnoreMessage(Exception):
105 """ A general class of message that we should ignore. """
106 pass
107 class IgnoreBulk(IgnoreMessage):
108 """ This is email from a mailing list or from a vacation program. """
109 pass
110 class IgnoreLoop(IgnoreMessage):
111 """ We've seen this message before... """
112 pass
114 def initialiseSecurity(security):
115 ''' Create some Permissions and Roles on the security object
117 This function is directly invoked by security.Security.__init__()
118 as a part of the Security object instantiation.
119 '''
120 security.addPermission(name="Email Registration",
121 description="Anonymous may register through e-mail")
122 p = security.addPermission(name="Email Access",
123 description="User may use the email interface")
124 security.addPermissionToRole('Admin', p)
126 def getparam(str, param):
127 ''' From the rfc822 "header" string, extract "param" if it appears.
128 '''
129 if ';' not in str:
130 return None
131 str = str[str.index(';'):]
132 while str[:1] == ';':
133 str = str[1:]
134 if ';' in str:
135 # XXX Should parse quotes!
136 end = str.index(';')
137 else:
138 end = len(str)
139 f = str[:end]
140 if '=' in f:
141 i = f.index('=')
142 if f[:i].strip().lower() == param:
143 return rfc822.unquote(f[i+1:].strip())
144 return None
146 class Message(mimetools.Message):
147 ''' subclass mimetools.Message so we can retrieve the parts of the
148 message...
149 '''
150 def getpart(self):
151 ''' Get a single part of a multipart message and return it as a new
152 Message instance.
153 '''
154 boundary = self.getparam('boundary')
155 mid, end = '--'+boundary, '--'+boundary+'--'
156 s = cStringIO.StringIO()
157 while 1:
158 line = self.fp.readline()
159 if not line:
160 break
161 if line.strip() in (mid, end):
162 break
163 s.write(line)
164 if not s.getvalue().strip():
165 return None
166 s.seek(0)
167 return Message(s)
169 def getparts(self):
170 """Get all parts of this multipart message."""
171 # skip over the intro to the first boundary
172 self.getpart()
174 # accumulate the other parts
175 parts = []
176 while 1:
177 part = self.getpart()
178 if part is None:
179 break
180 parts.append(part)
181 return parts
183 def getheader(self, name, default=None):
184 hdr = mimetools.Message.getheader(self, name, default)
185 if hdr:
186 hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
187 return rfc2822.decode_header(hdr)
189 def getname(self):
190 """Find an appropriate name for this message."""
191 if self.gettype() == 'message/rfc822':
192 # handle message/rfc822 specially - the name should be
193 # the subject of the actual e-mail embedded here
194 self.fp.seek(0)
195 name = Message(self.fp).getheader('subject')
196 else:
197 # try name on Content-Type
198 name = self.getparam('name')
199 if not name:
200 disp = self.getheader('content-disposition', None)
201 if disp:
202 name = getparam(disp, 'filename')
204 if name:
205 return name.strip()
207 def getbody(self):
208 """Get the decoded message body."""
209 self.rewindbody()
210 encoding = self.getencoding()
211 data = None
212 if encoding == 'base64':
213 # BUG: is base64 really used for text encoding or
214 # are we inserting zip files here.
215 data = binascii.a2b_base64(self.fp.read())
216 elif encoding == 'quoted-printable':
217 # the quopri module wants to work with files
218 decoded = cStringIO.StringIO()
219 quopri.decode(self.fp, decoded)
220 data = decoded.getvalue()
221 elif encoding == 'uuencoded':
222 data = binascii.a2b_uu(self.fp.read())
223 else:
224 # take it as text
225 data = self.fp.read()
227 # Encode message to unicode
228 charset = rfc2822.unaliasCharset(self.getparam("charset"))
229 if charset:
230 # Do conversion only if charset specified
231 edata = unicode(data, charset).encode('utf-8')
232 # Convert from dos eol to unix
233 edata = edata.replace('\r\n', '\n')
234 else:
235 # Leave message content as is
236 edata = data
238 return edata
240 # General multipart handling:
241 # Take the first text/plain part, anything else is considered an
242 # attachment.
243 # multipart/mixed: multiple "unrelated" parts.
244 # multipart/signed (rfc 1847):
245 # The control information is carried in the second of the two
246 # required body parts.
247 # ACTION: Default, so if content is text/plain we get it.
248 # multipart/encrypted (rfc 1847):
249 # The control information is carried in the first of the two
250 # required body parts.
251 # ACTION: Not handleable as the content is encrypted.
252 # multipart/related (rfc 1872, 2112, 2387):
253 # The Multipart/Related content-type addresses the MIME
254 # representation of compound objects.
255 # ACTION: Default. If we are lucky there is a text/plain.
256 # TODO: One should use the start part and look for an Alternative
257 # that is text/plain.
258 # multipart/Alternative (rfc 1872, 1892):
259 # only in "related" ?
260 # multipart/report (rfc 1892):
261 # e.g. mail system delivery status reports.
262 # ACTION: Default. Could be ignored or used for Delivery Notification
263 # flagging.
264 # multipart/form-data:
265 # For web forms only.
267 def extract_content(self, parent_type=None):
268 """Extract the body and the attachments recursively."""
269 content_type = self.gettype()
270 content = None
271 attachments = []
273 if content_type == 'text/plain':
274 content = self.getbody()
275 elif content_type[:10] == 'multipart/':
276 for part in self.getparts():
277 new_content, new_attach = part.extract_content(content_type)
279 # If we haven't found a text/plain part yet, take this one,
280 # otherwise make it an attachment.
281 if not content:
282 content = new_content
283 elif new_content:
284 attachments.append(part.as_attachment())
286 attachments.extend(new_attach)
287 elif (parent_type == 'multipart/signed' and
288 content_type == 'application/pgp-signature'):
289 # ignore it so it won't be saved as an attachment
290 pass
291 else:
292 attachments.append(self.as_attachment())
293 return content, attachments
295 def as_attachment(self):
296 """Return this message as an attachment."""
297 return (self.getname(), self.gettype(), self.getbody())
299 class MailGW:
301 # Matches subjects like:
302 # Re: "[issue1234] title of issue [status=resolved]"
303 subject_re = re.compile(r'''
304 (?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*\s* # Re:
305 (?P<quote>")? # Leading "
306 (\[(?P<classname>[^\d\s]+) # [issue..
307 (?P<nodeid>\d+)? # ..1234]
308 \])?\s*
309 (?P<title>[^[]+)? # issue title
310 "? # Trailing "
311 (\[(?P<args>.+?)\])? # [prop=value]
312 ''', re.IGNORECASE|re.VERBOSE)
314 def __init__(self, instance, db, arguments={}):
315 self.instance = instance
316 self.db = db
317 self.arguments = arguments
318 self.mailer = Mailer(instance.config)
320 # should we trap exceptions (normal usage) or pass them through
321 # (for testing)
322 self.trapExceptions = 1
324 def do_pipe(self):
325 """ Read a message from standard input and pass it to the mail handler.
327 Read into an internal structure that we can seek on (in case
328 there's an error).
330 XXX: we may want to read this into a temporary file instead...
331 """
332 s = cStringIO.StringIO()
333 s.write(sys.stdin.read())
334 s.seek(0)
335 self.main(s)
336 return 0
338 def do_mailbox(self, filename):
339 """ Read a series of messages from the specified unix mailbox file and
340 pass each to the mail handler.
341 """
342 # open the spool file and lock it
343 import fcntl
344 # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
345 if hasattr(fcntl, 'LOCK_EX'):
346 FCNTL = fcntl
347 else:
348 import FCNTL
349 f = open(filename, 'r+')
350 fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
352 # handle and clear the mailbox
353 try:
354 from mailbox import UnixMailbox
355 mailbox = UnixMailbox(f, factory=Message)
356 # grab one message
357 message = mailbox.next()
358 while message:
359 # handle this message
360 self.handle_Message(message)
361 message = mailbox.next()
362 # nuke the file contents
363 os.ftruncate(f.fileno(), 0)
364 except:
365 import traceback
366 traceback.print_exc()
367 return 1
368 fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
369 return 0
371 def do_imap(self, server, user='', password='', mailbox='', ssl=False):
372 ''' Do an IMAP connection
373 '''
374 import getpass, imaplib, socket
375 try:
376 if not user:
377 user = raw_input('User: ')
378 if not password:
379 password = getpass.getpass()
380 except (KeyboardInterrupt, EOFError):
381 # Ctrl C or D maybe also Ctrl Z under Windows.
382 print "\nAborted by user."
383 return 1
384 # open a connection to the server and retrieve all messages
385 try:
386 if ssl:
387 print 'Trying server "%s" with ssl' % server
388 server = imaplib.IMAP4_SSL(server)
389 else:
390 print 'Trying server %s without ssl' % server
391 server = imaplib.IMAP4(server)
392 except imaplib.IMAP4.error, e:
393 print 'IMAP server error:', e
394 return 1
395 except socket.error, e:
396 print 'SOCKET error:', e
397 return 1
398 except socket.sslerror, e:
399 print 'SOCKET ssl error:', e
400 return 1
402 try:
403 server.login(user, password)
404 except imaplib.IMAP4.error, e:
405 print 'Login failure:', e
406 return 1
408 try:
409 if not mailbox:
410 (typ, data) = server.select()
411 else:
412 (typ, data) = server.select(mailbox=mailbox)
413 if typ != 'OK':
414 print 'Failed to get mailbox "%s": %s'%(mailbox, data)
415 return 1
416 try:
417 numMessages = int(data[0])
418 except ValueError, value:
419 print 'Invalid message count from mailbox %r'%data[0]
420 return 1
421 for i in range(1, numMessages+1):
422 (typ, data) = server.fetch(str(i), '(RFC822)')
424 # mark the message as deleted.
425 server.store(str(i), '+FLAGS', r'(\Deleted)')
427 # process the message
428 s = cStringIO.StringIO(data[0][1])
429 s.seek(0)
430 self.handle_Message(Message(s))
431 server.close()
432 finally:
433 try:
434 server.expunge()
435 except:
436 pass
437 server.logout()
439 return 0
442 def do_apop(self, server, user='', password=''):
443 ''' Do authentication POP
444 '''
445 self.do_pop(server, user, password, apop=1)
447 def do_pop(self, server, user='', password='', apop=0):
448 '''Read a series of messages from the specified POP server.
449 '''
450 import getpass, poplib, socket
451 try:
452 if not user:
453 user = raw_input('User: ')
454 if not password:
455 password = getpass.getpass()
456 except (KeyboardInterrupt, EOFError):
457 # Ctrl C or D maybe also Ctrl Z under Windows.
458 print "\nAborted by user."
459 return 1
461 # open a connection to the server and retrieve all messages
462 try:
463 server = poplib.POP3(server)
464 except socket.error, message:
465 print "POP server error:", message
466 return 1
467 if apop:
468 server.apop(user, password)
469 else:
470 server.user(user)
471 server.pass_(password)
472 numMessages = len(server.list()[1])
473 for i in range(1, numMessages+1):
474 # retr: returns
475 # [ pop response e.g. '+OK 459 octets',
476 # [ array of message lines ],
477 # number of octets ]
478 lines = server.retr(i)[1]
479 s = cStringIO.StringIO('\n'.join(lines))
480 s.seek(0)
481 self.handle_Message(Message(s))
482 # delete the message
483 server.dele(i)
485 # quit the server to commit changes.
486 server.quit()
487 return 0
489 def main(self, fp):
490 ''' fp - the file from which to read the Message.
491 '''
492 return self.handle_Message(Message(fp))
494 def handle_Message(self, message):
495 """Handle an RFC822 Message
497 Handle the Message object by calling handle_message() and then cope
498 with any errors raised by handle_message.
499 This method's job is to make that call and handle any
500 errors in a sane manner. It should be replaced if you wish to
501 handle errors in a different manner.
502 """
503 # in some rare cases, a particularly stuffed-up e-mail will make
504 # its way into here... try to handle it gracefully
506 sendto = message.getaddrlist('resent-from')
507 if not sendto:
508 sendto = message.getaddrlist('from')
509 if not sendto:
510 # very bad-looking message - we don't even know who sent it
511 # XXX we should use a log file here...
513 sendto = [self.instance.config.ADMIN_EMAIL]
515 m = ['Subject: badly formed message from mail gateway']
516 m.append('')
517 m.append('The mail gateway retrieved a message which has no From:')
518 m.append('line, indicating that it is corrupt. Please check your')
519 m.append('mail gateway source. Failed message is attached.')
520 m.append('')
521 self.mailer.bounce_message(message, sendto, m,
522 subject='Badly formed message from mail gateway')
523 return
525 # try normal message-handling
526 if not self.trapExceptions:
527 return self.handle_message(message)
528 try:
529 return self.handle_message(message)
530 except MailUsageHelp:
531 # bounce the message back to the sender with the usage message
532 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
533 m = ['']
534 m.append('\n\nMail Gateway Help\n=================')
535 m.append(fulldoc)
536 self.mailer.bounce_message(message, [sendto[0][1]], m,
537 subject="Mail Gateway Help")
538 except MailUsageError, value:
539 # bounce the message back to the sender with the usage message
540 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
541 m = ['']
542 m.append(str(value))
543 m.append('\n\nMail Gateway Help\n=================')
544 m.append(fulldoc)
545 self.mailer.bounce_message(message, [sendto[0][1]], m)
546 except Unauthorized, value:
547 # just inform the user that he is not authorized
548 m = ['']
549 m.append(str(value))
550 self.mailer.bounce_message(message, [sendto[0][1]], m)
551 except IgnoreMessage:
552 # XXX we should use a log file here...
553 # do not take any action
554 # this exception is thrown when email should be ignored
555 return
556 except:
557 # bounce the message back to the sender with the error message
558 # XXX we should use a log file here...
559 # let the admin know that something very bad is happening
560 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
561 m = ['']
562 m.append('An unexpected error occurred during the processing')
563 m.append('of your message. The tracker administrator is being')
564 m.append('notified.\n')
565 m.append('---- traceback of failure ----')
566 s = cStringIO.StringIO()
567 import traceback
568 traceback.print_exc(None, s)
569 m.append(s.getvalue())
570 self.mailer.bounce_message(message, sendto, m)
572 def handle_message(self, message):
573 ''' message - a Message instance
575 Parse the message as per the module docstring.
576 '''
577 # detect loops
578 if message.getheader('x-roundup-loop', ''):
579 raise IgnoreLoop
581 # detect Precedence: Bulk
582 if (message.getheader('precedence', '') == 'bulk'):
583 raise IgnoreBulk
585 # XXX Don't enable. This doesn't work yet.
586 # "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
587 # handle delivery to addresses like:tracker+issue25@some.dom.ain
588 # use the embedded issue number as our issue
589 # if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
590 # self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
591 # issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
592 # for header in ['to', 'cc', 'bcc']:
593 # addresses = message.getheader(header, '')
594 # if addresses:
595 # # FIXME, this only finds the first match in the addresses.
596 # issue = re.search(issue_re, addresses, 'i')
597 # if issue:
598 # classname = issue.group('classname')
599 # nodeid = issue.group('nodeid')
600 # break
602 # determine the sender's address
603 from_list = message.getaddrlist('resent-from')
604 if not from_list:
605 from_list = message.getaddrlist('from')
607 # handle the subject line
608 subject = message.getheader('subject', '')
610 if not subject:
611 raise MailUsageError, '''
612 Emails to Roundup trackers must include a Subject: line!
613 '''
615 if subject.strip().lower() == 'help':
616 raise MailUsageHelp
618 m = self.subject_re.match(subject)
620 # check for well-formed subject line
621 if m:
622 # get the classname
623 classname = m.group('classname')
624 if classname is None:
625 # no classname, check if this a registration confirmation email
626 # or fallback on the default class
627 otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
628 otk = otk_re.search(m.group('title'))
629 if otk:
630 self.db.confirm_registration(otk.group('otk'))
631 subject = 'Your registration to %s is complete' % \
632 self.instance.config.TRACKER_NAME
633 sendto = [from_list[0][1]]
634 self.mailer.standard_message(sendto, subject, '')
635 return
636 elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
637 self.instance.config.MAIL_DEFAULT_CLASS:
638 classname = self.instance.config.MAIL_DEFAULT_CLASS
639 else:
640 # fail
641 m = None
643 if not m:
644 raise MailUsageError, """
645 The message you sent to roundup did not contain a properly formed subject
646 line. The subject must contain a class name or designator to indicate the
647 'topic' of the message. For example:
648 Subject: [issue] This is a new issue
649 - this will create a new issue in the tracker with the title 'This is
650 a new issue'.
651 Subject: [issue1234] This is a followup to issue 1234
652 - this will append the message's contents to the existing issue 1234
653 in the tracker.
655 Subject was: '%s'
656 """%subject
658 # get the class
659 try:
660 cl = self.db.getclass(classname)
661 except KeyError:
662 raise MailUsageError, '''
663 The class name you identified in the subject line ("%s") does not exist in the
664 database.
666 Valid class names are: %s
667 Subject was: "%s"
668 '''%(classname, ', '.join(self.db.getclasses()), subject)
670 # get the optional nodeid
671 nodeid = m.group('nodeid')
673 # title is optional too
674 title = m.group('title')
675 if title:
676 title = title.strip()
677 else:
678 title = ''
680 # strip off the quotes that dumb emailers put around the subject, like
681 # Re: "[issue1] bla blah"
682 if m.group('quote') and title.endswith('"'):
683 title = title[:-1]
685 # but we do need either a title or a nodeid...
686 if nodeid is None and not title:
687 raise MailUsageError, '''
688 I cannot match your message to a node in the database - you need to either
689 supply a full node identifier (with number, eg "[issue123]" or keep the
690 previous subject title intact so I can match that.
692 Subject was: "%s"
693 '''%subject
695 # If there's no nodeid, check to see if this is a followup and
696 # maybe someone's responded to the initial mail that created an
697 # entry. Try to find the matching nodes with the same title, and
698 # use the _last_ one matched (since that'll _usually_ be the most
699 # recent...)
700 if nodeid is None and m.group('refwd'):
701 l = cl.stringFind(title=title)
702 if l:
703 nodeid = l[-1]
705 # if a nodeid was specified, make sure it's valid
706 if nodeid is not None and not cl.hasnode(nodeid):
707 raise MailUsageError, '''
708 The node specified by the designator in the subject of your message ("%s")
709 does not exist.
711 Subject was: "%s"
712 '''%(nodeid, subject)
714 # Handle the arguments specified by the email gateway command line.
715 # We do this by looping over the list of self.arguments looking for
716 # a -C to tell us what class then the -S setting string.
717 msg_props = {}
718 user_props = {}
719 file_props = {}
720 issue_props = {}
721 # so, if we have any arguments, use them
722 if self.arguments:
723 current_class = 'msg'
724 for option, propstring in self.arguments:
725 if option in ( '-C', '--class'):
726 current_class = propstring.strip()
727 if current_class not in ('msg', 'file', 'user', 'issue'):
728 raise MailUsageError, '''
729 The mail gateway is not properly set up. Please contact
730 %s and have them fix the incorrect class specified as:
731 %s
732 '''%(self.instance.config.ADMIN_EMAIL, current_class)
733 if option in ('-S', '--set'):
734 if current_class == 'issue' :
735 errors, issue_props = setPropArrayFromString(self,
736 cl, propstring.strip(), nodeid)
737 elif current_class == 'file' :
738 temp_cl = self.db.getclass('file')
739 errors, file_props = setPropArrayFromString(self,
740 temp_cl, propstring.strip())
741 elif current_class == 'msg' :
742 temp_cl = self.db.getclass('msg')
743 errors, msg_props = setPropArrayFromString(self,
744 temp_cl, propstring.strip())
745 elif current_class == 'user' :
746 temp_cl = self.db.getclass('user')
747 errors, user_props = setPropArrayFromString(self,
748 temp_cl, propstring.strip())
749 if errors:
750 raise MailUsageError, '''
751 The mail gateway is not properly set up. Please contact
752 %s and have them fix the incorrect properties:
753 %s
754 '''%(self.instance.config.ADMIN_EMAIL, errors)
756 #
757 # handle the users
758 #
759 # Don't create users if anonymous isn't allowed to register
760 create = 1
761 anonid = self.db.user.lookup('anonymous')
762 if not self.db.security.hasPermission('Email Registration', anonid):
763 create = 0
765 # ok, now figure out who the author is - create a new user if the
766 # "create" flag is true
767 author = uidFromAddress(self.db, from_list[0], create=create)
769 # if we're not recognised, and we don't get added as a user, then we
770 # must be anonymous
771 if not author:
772 author = anonid
774 # make sure the author has permission to use the email interface
775 if not self.db.security.hasPermission('Email Access', author):
776 if author == anonid:
777 # we're anonymous and we need to be a registered user
778 raise Unauthorized, '''
779 You are not a registered user.
781 Unknown address: %s
782 '''%from_list[0][1]
783 else:
784 # we're registered and we're _still_ not allowed access
785 raise Unauthorized, 'You are not permitted to access '\
786 'this tracker.'
788 # make sure they're allowed to edit this class of information
789 if not self.db.security.hasPermission('Edit', author, classname):
790 raise Unauthorized, 'You are not permitted to edit %s.'%classname
792 # the author may have been created - make sure the change is
793 # committed before we reopen the database
794 self.db.commit()
796 # reopen the database as the author
797 username = self.db.user.get(author, 'username')
798 self.db.close()
799 self.db = self.instance.open(username)
801 # re-get the class with the new database connection
802 cl = self.db.getclass(classname)
804 # now update the recipients list
805 recipients = []
806 tracker_email = self.instance.config.TRACKER_EMAIL.lower()
807 for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
808 r = recipient[1].strip().lower()
809 if r == tracker_email or not r:
810 continue
812 # look up the recipient - create if necessary (and we're
813 # allowed to)
814 recipient = uidFromAddress(self.db, recipient, create, **user_props)
816 # if all's well, add the recipient to the list
817 if recipient:
818 recipients.append(recipient)
820 #
821 # handle the subject argument list
822 #
823 # figure what the properties of this Class are
824 properties = cl.getprops()
825 props = {}
826 args = m.group('args')
827 if args:
828 errors, props = setPropArrayFromString(self, cl, args, nodeid)
829 # handle any errors parsing the argument list
830 if errors:
831 errors = '\n- '.join(map(str, errors))
832 raise MailUsageError, '''
833 There were problems handling your subject line argument list:
834 - %s
836 Subject was: "%s"
837 '''%(errors, subject)
840 # set the issue title to the subject
841 if properties.has_key('title') and not issue_props.has_key('title'):
842 issue_props['title'] = title.strip()
844 #
845 # handle message-id and in-reply-to
846 #
847 messageid = message.getheader('message-id')
848 inreplyto = message.getheader('in-reply-to') or ''
849 # generate a messageid if there isn't one
850 if not messageid:
851 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
852 classname, nodeid, self.instance.config.MAIL_DOMAIN)
854 # now handle the body - find the message
855 content, attachments = message.extract_content()
856 if content is None:
857 raise MailUsageError, '''
858 Roundup requires the submission to be plain text. The message parser could
859 not find a text/plain part to use.
860 '''
862 # figure how much we should muck around with the email body
863 keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
864 'no') == 'yes'
865 keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
866 'no') == 'yes'
868 # parse the body of the message, stripping out bits as appropriate
869 summary, content = parseContent(content, keep_citations,
870 keep_body)
871 content = content.strip()
873 #
874 # handle the attachments
875 #
876 if properties.has_key('files'):
877 files = []
878 for (name, mime_type, data) in attachments:
879 if not name:
880 name = "unnamed"
881 try:
882 fileid = self.db.file.create(type=mime_type, name=name,
883 content=data, **file_props)
884 except exceptions.Reject:
885 pass
886 else:
887 files.append(fileid)
888 # attach the files to the issue
889 if nodeid:
890 # extend the existing files list
891 fileprop = cl.get(nodeid, 'files')
892 fileprop.extend(files)
893 props['files'] = fileprop
894 else:
895 # pre-load the files list
896 props['files'] = files
898 #
899 # create the message if there's a message body (content)
900 #
901 if (content and properties.has_key('messages')):
902 try:
903 message_id = self.db.msg.create(author=author,
904 recipients=recipients, date=date.Date('.'),
905 summary=summary, content=content, files=files,
906 messageid=messageid, inreplyto=inreplyto, **msg_props)
907 except exceptions.Reject:
908 pass
909 else:
910 # attach the message to the node
911 if nodeid:
912 # add the message to the node's list
913 messages = cl.get(nodeid, 'messages')
914 messages.append(message_id)
915 props['messages'] = messages
916 else:
917 # pre-load the messages list
918 props['messages'] = [message_id]
920 #
921 # perform the node change / create
922 #
923 try:
924 # merge the command line props defined in issue_props into
925 # the props dictionary because function(**props, **issue_props)
926 # is a syntax error.
927 for prop in issue_props.keys() :
928 if not props.has_key(prop) :
929 props[prop] = issue_props[prop]
930 if nodeid:
931 cl.set(nodeid, **props)
932 else:
933 nodeid = cl.create(**props)
934 except (TypeError, IndexError, ValueError), message:
935 raise MailUsageError, '''
936 There was a problem with the message you sent:
937 %s
938 '''%message
940 # commit the changes to the DB
941 self.db.commit()
943 return nodeid
946 def setPropArrayFromString(self, cl, propString, nodeid=None):
947 ''' takes string of form prop=value,value;prop2=value
948 and returns (error, prop[..])
949 '''
950 props = {}
951 errors = []
952 for prop in string.split(propString, ';'):
953 # extract the property name and value
954 try:
955 propname, value = prop.split('=')
956 except ValueError, message:
957 errors.append('not of form [arg=value,value,...;'
958 'arg=value,value,...]')
959 return (errors, props)
960 # convert the value to a hyperdb-usable value
961 propname = propname.strip()
962 try:
963 props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
964 propname, value)
965 except hyperdb.HyperdbValueError, message:
966 errors.append(message)
967 return errors, props
970 def extractUserFromList(userClass, users):
971 '''Given a list of users, try to extract the first non-anonymous user
972 and return that user, otherwise return None
973 '''
974 if len(users) > 1:
975 for user in users:
976 # make sure we don't match the anonymous or admin user
977 if userClass.get(user, 'username') in ('admin', 'anonymous'):
978 continue
979 # first valid match will do
980 return user
981 # well, I guess we have no choice
982 return user[0]
983 elif users:
984 return users[0]
985 return None
988 def uidFromAddress(db, address, create=1, **user_props):
989 ''' address is from the rfc822 module, and therefore is (name, addr)
991 user is created if they don't exist in the db already
992 user_props may supply additional user information
993 '''
994 (realname, address) = address
996 # try a straight match of the address
997 user = extractUserFromList(db.user, db.user.stringFind(address=address))
998 if user is not None:
999 return user
1001 # try the user alternate addresses if possible
1002 props = db.user.getprops()
1003 if props.has_key('alternate_addresses'):
1004 users = db.user.filter(None, {'alternate_addresses': address})
1005 user = extractUserFromList(db.user, users)
1006 if user is not None:
1007 return user
1009 # try to match the username to the address (for local
1010 # submissions where the address is empty)
1011 user = extractUserFromList(db.user, db.user.stringFind(username=address))
1013 # couldn't match address or username, so create a new user
1014 if create:
1015 # generate a username
1016 if '@' in address:
1017 username = address.split('@')[0]
1018 else:
1019 username = address
1020 trying = username
1021 n = 0
1022 while 1:
1023 try:
1024 # does this username exist already?
1025 db.user.lookup(trying)
1026 except KeyError:
1027 break
1028 n += 1
1029 trying = username + str(n)
1031 # create!
1032 try:
1033 return db.user.create(username=trying, address=address,
1034 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1035 password=password.Password(password.generatePassword()),
1036 **user_props)
1037 except exceptions.Reject:
1038 return 0
1039 else:
1040 return 0
1043 def parseContent(content, keep_citations, keep_body,
1044 blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
1045 eol=re.compile(r'[\r\n]+'),
1046 signature=re.compile(r'^[>|\s]*-- ?$'),
1047 original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
1048 ''' The message body is divided into sections by blank lines.
1049 Sections where the second and all subsequent lines begin with a ">"
1050 or "|" character are considered "quoting sections". The first line of
1051 the first non-quoting section becomes the summary of the message.
1053 If keep_citations is true, then we keep the "quoting sections" in the
1054 content.
1055 If keep_body is true, we even keep the signature sections.
1056 '''
1057 # strip off leading carriage-returns / newlines
1058 i = 0
1059 for i in range(len(content)):
1060 if content[i] not in '\r\n':
1061 break
1062 if i > 0:
1063 sections = blank_line.split(content[i:])
1064 else:
1065 sections = blank_line.split(content)
1067 # extract out the summary from the message
1068 summary = ''
1069 l = []
1070 for section in sections:
1071 #section = section.strip()
1072 if not section:
1073 continue
1074 lines = eol.split(section)
1075 if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1076 lines[1] and lines[1][0] in '>|'):
1077 # see if there's a response somewhere inside this section (ie.
1078 # no blank line between quoted message and response)
1079 for line in lines[1:]:
1080 if line and line[0] not in '>|':
1081 break
1082 else:
1083 # we keep quoted bits if specified in the config
1084 if keep_citations:
1085 l.append(section)
1086 continue
1087 # keep this section - it has reponse stuff in it
1088 lines = lines[lines.index(line):]
1089 section = '\n'.join(lines)
1090 # and while we're at it, use the first non-quoted bit as
1091 # our summary
1092 summary = section
1094 if not summary:
1095 # if we don't have our summary yet use the first line of this
1096 # section
1097 summary = section
1098 elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1099 # lose any signature
1100 break
1101 elif original_msg.match(lines[0]):
1102 # ditch the stupid Outlook quoting of the entire original message
1103 break
1105 # and add the section to the output
1106 l.append(section)
1108 # figure the summary - find the first sentence-ending punctuation or the
1109 # first whole line, whichever is longest
1110 sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1111 if sentence:
1112 sentence = sentence.group(1)
1113 else:
1114 sentence = ''
1115 first = eol.split(summary)[0]
1116 summary = max(sentence, first)
1118 # Now reconstitute the message content minus the bits we don't care
1119 # about.
1120 if not keep_body:
1121 content = '\n\n'.join(l)
1123 return summary, content
1125 # vim: set filetype=python ts=4 sw=4 et si