35cc90248470b73aedc5cd2bacaa1d232c0bf1b6
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.147 2004-04-13 04:11:06 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 #print 'Using INBOX'
411 (typ, data) = server.select()
412 else:
413 #print 'Using mailbox' , mailbox
414 (typ, data) = server.select(mailbox=mailbox)
415 if typ != 'OK':
416 print 'Failed to get mailbox "%s": %s' % (mailbox, data)
417 return 1
418 try:
419 numMessages = int(data[0])
420 #print 'Found %s messages' % numMessages
421 except ValueError:
422 print 'Invalid return value from mailbox'
423 return 1
424 for i in range(1, numMessages+1):
425 #print 'Processing message ', i
426 (typ, data) = server.fetch(str(i), '(RFC822)')
427 #This marks the message as deleted.
428 server.store(str(i), '+FLAGS', r'(\Deleted)')
429 #This is the raw text of the message
430 s = cStringIO.StringIO(data[0][1])
431 s.seek(0)
432 self.handle_Message(Message(s))
433 server.close()
434 finally:
435 try:
436 server.expunge()
437 except:
438 pass
439 server.logout()
441 return 0
444 def do_apop(self, server, user='', password=''):
445 ''' Do authentication POP
446 '''
447 self.do_pop(server, user, password, apop=1)
449 def do_pop(self, server, user='', password='', apop=0):
450 '''Read a series of messages from the specified POP server.
451 '''
452 import getpass, poplib, socket
453 try:
454 if not user:
455 user = raw_input('User: ')
456 if not password:
457 password = getpass.getpass()
458 except (KeyboardInterrupt, EOFError):
459 # Ctrl C or D maybe also Ctrl Z under Windows.
460 print "\nAborted by user."
461 return 1
463 # open a connection to the server and retrieve all messages
464 try:
465 server = poplib.POP3(server)
466 except socket.error, message:
467 print "POP server error:", message
468 return 1
469 if apop:
470 server.apop(user, password)
471 else:
472 server.user(user)
473 server.pass_(password)
474 numMessages = len(server.list()[1])
475 for i in range(1, numMessages+1):
476 # retr: returns
477 # [ pop response e.g. '+OK 459 octets',
478 # [ array of message lines ],
479 # number of octets ]
480 lines = server.retr(i)[1]
481 s = cStringIO.StringIO('\n'.join(lines))
482 s.seek(0)
483 self.handle_Message(Message(s))
484 # delete the message
485 server.dele(i)
487 # quit the server to commit changes.
488 server.quit()
489 return 0
491 def main(self, fp):
492 ''' fp - the file from which to read the Message.
493 '''
494 return self.handle_Message(Message(fp))
496 def handle_Message(self, message):
497 """Handle an RFC822 Message
499 Handle the Message object by calling handle_message() and then cope
500 with any errors raised by handle_message.
501 This method's job is to make that call and handle any
502 errors in a sane manner. It should be replaced if you wish to
503 handle errors in a different manner.
504 """
505 # in some rare cases, a particularly stuffed-up e-mail will make
506 # its way into here... try to handle it gracefully
508 sendto = message.getaddrlist('resent-from')
509 if not sendto:
510 sendto = message.getaddrlist('from')
511 if not sendto:
512 # very bad-looking message - we don't even know who sent it
513 # XXX we should use a log file here...
515 sendto = [self.instance.config.ADMIN_EMAIL]
517 m = ['Subject: badly formed message from mail gateway']
518 m.append('')
519 m.append('The mail gateway retrieved a message which has no From:')
520 m.append('line, indicating that it is corrupt. Please check your')
521 m.append('mail gateway source. Failed message is attached.')
522 m.append('')
523 self.mailer.bounce_message(message, sendto, m,
524 subject='Badly formed message from mail gateway')
525 return
527 # try normal message-handling
528 if not self.trapExceptions:
529 return self.handle_message(message)
530 try:
531 return self.handle_message(message)
532 except MailUsageHelp:
533 # bounce the message back to the sender with the usage message
534 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
535 m = ['']
536 m.append('\n\nMail Gateway Help\n=================')
537 m.append(fulldoc)
538 self.mailer.bounce_message(message, [sendto[0][1]], m,
539 subject="Mail Gateway Help")
540 except MailUsageError, value:
541 # bounce the message back to the sender with the usage message
542 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
543 m = ['']
544 m.append(str(value))
545 m.append('\n\nMail Gateway Help\n=================')
546 m.append(fulldoc)
547 self.mailer.bounce_message(message, [sendto[0][1]], m)
548 except Unauthorized, value:
549 # just inform the user that he is not authorized
550 m = ['']
551 m.append(str(value))
552 self.mailer.bounce_message(message, [sendto[0][1]], m)
553 except IgnoreMessage:
554 # XXX we should use a log file here...
555 # do not take any action
556 # this exception is thrown when email should be ignored
557 return
558 except:
559 # bounce the message back to the sender with the error message
560 # XXX we should use a log file here...
561 # let the admin know that something very bad is happening
562 sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
563 m = ['']
564 m.append('An unexpected error occurred during the processing')
565 m.append('of your message. The tracker administrator is being')
566 m.append('notified.\n')
567 m.append('---- traceback of failure ----')
568 s = cStringIO.StringIO()
569 import traceback
570 traceback.print_exc(None, s)
571 m.append(s.getvalue())
572 self.mailer.bounce_message(message, sendto, m)
574 def handle_message(self, message):
575 ''' message - a Message instance
577 Parse the message as per the module docstring.
578 '''
579 # detect loops
580 if message.getheader('x-roundup-loop', ''):
581 raise IgnoreLoop
583 # detect Precedence: Bulk
584 if (message.getheader('precedence', '') == 'bulk'):
585 raise IgnoreBulk
587 # XXX Don't enable. This doesn't work yet.
588 # "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
589 # handle delivery to addresses like:tracker+issue25@some.dom.ain
590 # use the embedded issue number as our issue
591 # if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
592 # self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
593 # issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
594 # for header in ['to', 'cc', 'bcc']:
595 # addresses = message.getheader(header, '')
596 # if addresses:
597 # # FIXME, this only finds the first match in the addresses.
598 # issue = re.search(issue_re, addresses, 'i')
599 # if issue:
600 # classname = issue.group('classname')
601 # nodeid = issue.group('nodeid')
602 # break
604 # determine the sender's address
605 from_list = message.getaddrlist('resent-from')
606 if not from_list:
607 from_list = message.getaddrlist('from')
609 # handle the subject line
610 subject = message.getheader('subject', '')
612 if not subject:
613 raise MailUsageError, '''
614 Emails to Roundup trackers must include a Subject: line!
615 '''
617 if subject.strip().lower() == 'help':
618 raise MailUsageHelp
620 m = self.subject_re.match(subject)
622 # check for well-formed subject line
623 if m:
624 # get the classname
625 classname = m.group('classname')
626 if classname is None:
627 # no classname, check if this a registration confirmation email
628 # or fallback on the default class
629 otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
630 otk = otk_re.search(m.group('title'))
631 if otk:
632 self.db.confirm_registration(otk.group('otk'))
633 subject = 'Your registration to %s is complete' % \
634 self.instance.config.TRACKER_NAME
635 sendto = [from_list[0][1]]
636 self.mailer.standard_message(sendto, subject, '')
637 return
638 elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
639 self.instance.config.MAIL_DEFAULT_CLASS:
640 classname = self.instance.config.MAIL_DEFAULT_CLASS
641 else:
642 # fail
643 m = None
645 if not m:
646 raise MailUsageError, """
647 The message you sent to roundup did not contain a properly formed subject
648 line. The subject must contain a class name or designator to indicate the
649 'topic' of the message. For example:
650 Subject: [issue] This is a new issue
651 - this will create a new issue in the tracker with the title 'This is
652 a new issue'.
653 Subject: [issue1234] This is a followup to issue 1234
654 - this will append the message's contents to the existing issue 1234
655 in the tracker.
657 Subject was: '%s'
658 """%subject
660 # get the class
661 try:
662 cl = self.db.getclass(classname)
663 except KeyError:
664 raise MailUsageError, '''
665 The class name you identified in the subject line ("%s") does not exist in the
666 database.
668 Valid class names are: %s
669 Subject was: "%s"
670 '''%(classname, ', '.join(self.db.getclasses()), subject)
672 # get the optional nodeid
673 nodeid = m.group('nodeid')
675 # title is optional too
676 title = m.group('title')
677 if title:
678 title = title.strip()
679 else:
680 title = ''
682 # strip off the quotes that dumb emailers put around the subject, like
683 # Re: "[issue1] bla blah"
684 if m.group('quote') and title.endswith('"'):
685 title = title[:-1]
687 # but we do need either a title or a nodeid...
688 if nodeid is None and not title:
689 raise MailUsageError, '''
690 I cannot match your message to a node in the database - you need to either
691 supply a full node identifier (with number, eg "[issue123]" or keep the
692 previous subject title intact so I can match that.
694 Subject was: "%s"
695 '''%subject
697 # If there's no nodeid, check to see if this is a followup and
698 # maybe someone's responded to the initial mail that created an
699 # entry. Try to find the matching nodes with the same title, and
700 # use the _last_ one matched (since that'll _usually_ be the most
701 # recent...)
702 if nodeid is None and m.group('refwd'):
703 l = cl.stringFind(title=title)
704 if l:
705 nodeid = l[-1]
707 # if a nodeid was specified, make sure it's valid
708 if nodeid is not None and not cl.hasnode(nodeid):
709 raise MailUsageError, '''
710 The node specified by the designator in the subject of your message ("%s")
711 does not exist.
713 Subject was: "%s"
714 '''%(nodeid, subject)
716 # Handle the arguments specified by the email gateway command line.
717 # We do this by looping over the list of self.arguments looking for
718 # a -C to tell us what class then the -S setting string.
719 msg_props = {}
720 user_props = {}
721 file_props = {}
722 issue_props = {}
723 # so, if we have any arguments, use them
724 if self.arguments:
725 current_class = 'msg'
726 for option, propstring in self.arguments:
727 if option in ( '-C', '--class'):
728 current_class = propstring.strip()
729 if current_class not in ('msg', 'file', 'user', 'issue'):
730 raise MailUsageError, '''
731 The mail gateway is not properly set up. Please contact
732 %s and have them fix the incorrect class specified as:
733 %s
734 '''%(self.instance.config.ADMIN_EMAIL, current_class)
735 if option in ('-S', '--set'):
736 if current_class == 'issue' :
737 errors, issue_props = setPropArrayFromString(self,
738 cl, propstring.strip(), nodeid)
739 elif current_class == 'file' :
740 temp_cl = self.db.getclass('file')
741 errors, file_props = setPropArrayFromString(self,
742 temp_cl, propstring.strip())
743 elif current_class == 'msg' :
744 temp_cl = self.db.getclass('msg')
745 errors, msg_props = setPropArrayFromString(self,
746 temp_cl, propstring.strip())
747 elif current_class == 'user' :
748 temp_cl = self.db.getclass('user')
749 errors, user_props = setPropArrayFromString(self,
750 temp_cl, propstring.strip())
751 if errors:
752 raise MailUsageError, '''
753 The mail gateway is not properly set up. Please contact
754 %s and have them fix the incorrect properties:
755 %s
756 '''%(self.instance.config.ADMIN_EMAIL, errors)
758 #
759 # handle the users
760 #
761 # Don't create users if anonymous isn't allowed to register
762 create = 1
763 anonid = self.db.user.lookup('anonymous')
764 if not self.db.security.hasPermission('Email Registration', anonid):
765 create = 0
767 # ok, now figure out who the author is - create a new user if the
768 # "create" flag is true
769 author = uidFromAddress(self.db, from_list[0], create=create)
771 # if we're not recognised, and we don't get added as a user, then we
772 # must be anonymous
773 if not author:
774 author = anonid
776 # make sure the author has permission to use the email interface
777 if not self.db.security.hasPermission('Email Access', author):
778 if author == anonid:
779 # we're anonymous and we need to be a registered user
780 raise Unauthorized, '''
781 You are not a registered user.
783 Unknown address: %s
784 '''%from_list[0][1]
785 else:
786 # we're registered and we're _still_ not allowed access
787 raise Unauthorized, 'You are not permitted to access '\
788 'this tracker.'
790 # make sure they're allowed to edit this class of information
791 if not self.db.security.hasPermission('Edit', author, classname):
792 raise Unauthorized, 'You are not permitted to edit %s.'%classname
794 # the author may have been created - make sure the change is
795 # committed before we reopen the database
796 self.db.commit()
798 # reopen the database as the author
799 username = self.db.user.get(author, 'username')
800 self.db.close()
801 self.db = self.instance.open(username)
803 # re-get the class with the new database connection
804 cl = self.db.getclass(classname)
806 # now update the recipients list
807 recipients = []
808 tracker_email = self.instance.config.TRACKER_EMAIL.lower()
809 for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
810 r = recipient[1].strip().lower()
811 if r == tracker_email or not r:
812 continue
814 # look up the recipient - create if necessary (and we're
815 # allowed to)
816 recipient = uidFromAddress(self.db, recipient, create, **user_props)
818 # if all's well, add the recipient to the list
819 if recipient:
820 recipients.append(recipient)
822 #
823 # handle the subject argument list
824 #
825 # figure what the properties of this Class are
826 properties = cl.getprops()
827 props = {}
828 args = m.group('args')
829 if args:
830 errors, props = setPropArrayFromString(self, cl, args, nodeid)
831 # handle any errors parsing the argument list
832 if errors:
833 errors = '\n- '.join(map(str, errors))
834 raise MailUsageError, '''
835 There were problems handling your subject line argument list:
836 - %s
838 Subject was: "%s"
839 '''%(errors, subject)
842 # set the issue title to the subject
843 if properties.has_key('title') and not issue_props.has_key('title'):
844 issue_props['title'] = title.strip()
846 #
847 # handle message-id and in-reply-to
848 #
849 messageid = message.getheader('message-id')
850 inreplyto = message.getheader('in-reply-to') or ''
851 # generate a messageid if there isn't one
852 if not messageid:
853 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
854 classname, nodeid, self.instance.config.MAIL_DOMAIN)
856 # now handle the body - find the message
857 content, attachments = message.extract_content()
858 if content is None:
859 raise MailUsageError, '''
860 Roundup requires the submission to be plain text. The message parser could
861 not find a text/plain part to use.
862 '''
864 # figure how much we should muck around with the email body
865 keep_citations = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
866 'no') == 'yes'
867 keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
868 'no') == 'yes'
870 # parse the body of the message, stripping out bits as appropriate
871 summary, content = parseContent(content, keep_citations,
872 keep_body)
873 content = content.strip()
875 #
876 # handle the attachments
877 #
878 if properties.has_key('files'):
879 files = []
880 for (name, mime_type, data) in attachments:
881 if not name:
882 name = "unnamed"
883 try:
884 fileid = self.db.file.create(type=mime_type, name=name,
885 content=data, **file_props)
886 except exceptions.Reject:
887 pass
888 else:
889 files.append(fileid)
890 # attach the files to the issue
891 if nodeid:
892 # extend the existing files list
893 fileprop = cl.get(nodeid, 'files')
894 fileprop.extend(files)
895 props['files'] = fileprop
896 else:
897 # pre-load the files list
898 props['files'] = files
900 #
901 # create the message if there's a message body (content)
902 #
903 if (content and properties.has_key('messages')):
904 try:
905 message_id = self.db.msg.create(author=author,
906 recipients=recipients, date=date.Date('.'),
907 summary=summary, content=content, files=files,
908 messageid=messageid, inreplyto=inreplyto, **msg_props)
909 except exceptions.Reject:
910 pass
911 else:
912 # attach the message to the node
913 if nodeid:
914 # add the message to the node's list
915 messages = cl.get(nodeid, 'messages')
916 messages.append(message_id)
917 props['messages'] = messages
918 else:
919 # pre-load the messages list
920 props['messages'] = [message_id]
922 #
923 # perform the node change / create
924 #
925 try:
926 # merge the command line props defined in issue_props into
927 # the props dictionary because function(**props, **issue_props)
928 # is a syntax error.
929 for prop in issue_props.keys() :
930 if not props.has_key(prop) :
931 props[prop] = issue_props[prop]
932 if nodeid:
933 cl.set(nodeid, **props)
934 else:
935 nodeid = cl.create(**props)
936 except (TypeError, IndexError, ValueError), message:
937 raise MailUsageError, '''
938 There was a problem with the message you sent:
939 %s
940 '''%message
942 # commit the changes to the DB
943 self.db.commit()
945 return nodeid
948 def setPropArrayFromString(self, cl, propString, nodeid=None):
949 ''' takes string of form prop=value,value;prop2=value
950 and returns (error, prop[..])
951 '''
952 props = {}
953 errors = []
954 for prop in string.split(propString, ';'):
955 # extract the property name and value
956 try:
957 propname, value = prop.split('=')
958 except ValueError, message:
959 errors.append('not of form [arg=value,value,...;'
960 'arg=value,value,...]')
961 return (errors, props)
962 # convert the value to a hyperdb-usable value
963 propname = propname.strip()
964 try:
965 props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
966 propname, value)
967 except hyperdb.HyperdbValueError, message:
968 errors.append(message)
969 return errors, props
972 def extractUserFromList(userClass, users):
973 '''Given a list of users, try to extract the first non-anonymous user
974 and return that user, otherwise return None
975 '''
976 if len(users) > 1:
977 for user in users:
978 # make sure we don't match the anonymous or admin user
979 if userClass.get(user, 'username') in ('admin', 'anonymous'):
980 continue
981 # first valid match will do
982 return user
983 # well, I guess we have no choice
984 return user[0]
985 elif users:
986 return users[0]
987 return None
990 def uidFromAddress(db, address, create=1, **user_props):
991 ''' address is from the rfc822 module, and therefore is (name, addr)
993 user is created if they don't exist in the db already
994 user_props may supply additional user information
995 '''
996 (realname, address) = address
998 # try a straight match of the address
999 user = extractUserFromList(db.user, db.user.stringFind(address=address))
1000 if user is not None:
1001 return user
1003 # try the user alternate addresses if possible
1004 props = db.user.getprops()
1005 if props.has_key('alternate_addresses'):
1006 users = db.user.filter(None, {'alternate_addresses': address})
1007 user = extractUserFromList(db.user, users)
1008 if user is not None:
1009 return user
1011 # try to match the username to the address (for local
1012 # submissions where the address is empty)
1013 user = extractUserFromList(db.user, db.user.stringFind(username=address))
1015 # couldn't match address or username, so create a new user
1016 if create:
1017 # generate a username
1018 if '@' in address:
1019 username = address.split('@')[0]
1020 else:
1021 username = address
1022 trying = username
1023 n = 0
1024 while 1:
1025 try:
1026 # does this username exist already?
1027 db.user.lookup(trying)
1028 except KeyError:
1029 break
1030 n += 1
1031 trying = username + str(n)
1033 # create!
1034 try:
1035 return db.user.create(username=trying, address=address,
1036 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1037 password=password.Password(password.generatePassword()),
1038 **user_props)
1039 except exceptions.Reject:
1040 return 0
1041 else:
1042 return 0
1045 def parseContent(content, keep_citations, keep_body,
1046 blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
1047 eol=re.compile(r'[\r\n]+'),
1048 signature=re.compile(r'^[>|\s]*-- ?$'),
1049 original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
1050 ''' The message body is divided into sections by blank lines.
1051 Sections where the second and all subsequent lines begin with a ">"
1052 or "|" character are considered "quoting sections". The first line of
1053 the first non-quoting section becomes the summary of the message.
1055 If keep_citations is true, then we keep the "quoting sections" in the
1056 content.
1057 If keep_body is true, we even keep the signature sections.
1058 '''
1059 # strip off leading carriage-returns / newlines
1060 i = 0
1061 for i in range(len(content)):
1062 if content[i] not in '\r\n':
1063 break
1064 if i > 0:
1065 sections = blank_line.split(content[i:])
1066 else:
1067 sections = blank_line.split(content)
1069 # extract out the summary from the message
1070 summary = ''
1071 l = []
1072 for section in sections:
1073 #section = section.strip()
1074 if not section:
1075 continue
1076 lines = eol.split(section)
1077 if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1078 lines[1] and lines[1][0] in '>|'):
1079 # see if there's a response somewhere inside this section (ie.
1080 # no blank line between quoted message and response)
1081 for line in lines[1:]:
1082 if line and line[0] not in '>|':
1083 break
1084 else:
1085 # we keep quoted bits if specified in the config
1086 if keep_citations:
1087 l.append(section)
1088 continue
1089 # keep this section - it has reponse stuff in it
1090 lines = lines[lines.index(line):]
1091 section = '\n'.join(lines)
1092 # and while we're at it, use the first non-quoted bit as
1093 # our summary
1094 summary = section
1096 if not summary:
1097 # if we don't have our summary yet use the first line of this
1098 # section
1099 summary = section
1100 elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1101 # lose any signature
1102 break
1103 elif original_msg.match(lines[0]):
1104 # ditch the stupid Outlook quoting of the entire original message
1105 break
1107 # and add the section to the output
1108 l.append(section)
1110 # figure the summary - find the first sentence-ending punctuation or the
1111 # first whole line, whichever is longest
1112 sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1113 if sentence:
1114 sentence = sentence.group(1)
1115 else:
1116 sentence = ''
1117 first = eol.split(summary)[0]
1118 summary = max(sentence, first)
1120 # Now reconstitute the message content minus the bits we don't care
1121 # about.
1122 if not keep_body:
1123 content = '\n\n'.join(l)
1125 return summary, content
1127 # vim: set filetype=python ts=4 sw=4 et si