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