1 # -*- coding: utf-8 -*-
2 #
3 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
7 #
8 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
9 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
10 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
11 # POSSIBILITY OF SUCH DAMAGE.
12 #
13 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
14 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
15 # FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
16 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
17 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
18 #
20 """An e-mail gateway for Roundup.
22 Incoming messages are examined for multiple parts:
23 . In a multipart/mixed message or part, each subpart is extracted and
24 examined. The text/plain subparts are assembled to form the textual
25 body of the message, to be stored in the file associated with a "msg"
26 class node. Any parts of other types are each stored in separate files
27 and given "file" class nodes that are linked to the "msg" node.
28 . In a multipart/alternative message or part, we look for a text/plain
29 subpart and ignore the other parts.
31 Summary
32 -------
33 The "summary" property on message nodes is taken from the first non-quoting
34 section in the message body. The message body is divided into sections by
35 blank lines. Sections where the second and all subsequent lines begin with
36 a ">" or "|" character are considered "quoting sections". The first line of
37 the first non-quoting section becomes the summary of the message.
39 Addresses
40 ---------
41 All of the addresses in the To: and Cc: headers of the incoming message are
42 looked up among the user nodes, and the corresponding users are placed in
43 the "recipients" property on the new "msg" node. The address in the From:
44 header similarly determines the "author" property of the new "msg"
45 node. The default handling for addresses that don't have corresponding
46 users is to create new users with no passwords and a username equal to the
47 address. (The web interface does not permit logins for users with no
48 passwords.) If we prefer to reject mail from outside sources, we can simply
49 register an auditor on the "user" class that prevents the creation of user
50 nodes with no passwords.
52 Actions
53 -------
54 The subject line of the incoming message is examined to determine whether
55 the message is an attempt to create a new item or to discuss an existing
56 item. A designator enclosed in square brackets is sought as the first thing
57 on the subject line (after skipping any "Fwd:" or "Re:" prefixes).
59 If an item designator (class name and id number) is found there, the newly
60 created "msg" node is added to the "messages" property for that item, and
61 any new "file" nodes are added to the "files" property for the item.
63 If just an item class name is found there, we attempt to create a new item
64 of that class with its "messages" property initialized to contain the new
65 "msg" node and its "files" property initialized to contain any new "file"
66 nodes.
68 Triggers
69 --------
70 Both cases may trigger detectors (in the first case we are calling the
71 set() method to add the message to the item's spool; in the second case we
72 are calling the create() method to create a new node). If an auditor raises
73 an exception, the original message is bounced back to the sender with the
74 explanatory message given in the exception.
76 $Id: mailgw.py,v 1.196 2008-07-23 03:04:44 richard Exp $
77 """
78 __docformat__ = 'restructuredtext'
80 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
81 import time, random, sys, logging
82 import traceback, MimeWriter, rfc822
84 from roundup import configuration, hyperdb, date, password, rfc2822, exceptions
85 from roundup.mailer import Mailer, MessageSendError
86 from roundup.i18n import _
88 try:
89 import pyme, pyme.core, pyme.gpgme
90 except ImportError:
91 pyme = None
93 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
95 class MailGWError(ValueError):
96 pass
98 class MailUsageError(ValueError):
99 pass
101 class MailUsageHelp(Exception):
102 """ We need to send the help message to the user. """
103 pass
105 class Unauthorized(Exception):
106 """ Access denied """
107 pass
109 class IgnoreMessage(Exception):
110 """ A general class of message that we should ignore. """
111 pass
112 class IgnoreBulk(IgnoreMessage):
113 """ This is email from a mailing list or from a vacation program. """
114 pass
115 class IgnoreLoop(IgnoreMessage):
116 """ We've seen this message before... """
117 pass
119 def initialiseSecurity(security):
120 ''' Create some Permissions and Roles on the security object
122 This function is directly invoked by security.Security.__init__()
123 as a part of the Security object instantiation.
124 '''
125 p = security.addPermission(name="Email Access",
126 description="User may use the email interface")
127 security.addPermissionToRole('Admin', p)
129 def getparam(str, param):
130 ''' From the rfc822 "header" string, extract "param" if it appears.
131 '''
132 if ';' not in str:
133 return None
134 str = str[str.index(';'):]
135 while str[:1] == ';':
136 str = str[1:]
137 if ';' in str:
138 # XXX Should parse quotes!
139 end = str.index(';')
140 else:
141 end = len(str)
142 f = str[:end]
143 if '=' in f:
144 i = f.index('=')
145 if f[:i].strip().lower() == param:
146 return rfc822.unquote(f[i+1:].strip())
147 return None
149 def gpgh_key_getall(key, attr):
150 ''' return list of given attribute for all uids in
151 a key
152 '''
153 u = key.uids
154 while u:
155 yield getattr(u, attr)
156 u = u.next
158 def gpgh_sigs(sig):
159 ''' more pythonic iteration over GPG signatures '''
160 while sig:
161 yield sig
162 sig = sig.next
165 def iter_roles(roles):
166 ''' handle the text processing of turning the roles list
167 into something python can use more easily
168 '''
169 for role in [x.lower().strip() for x in roles.split(',')]:
170 yield role
172 def user_has_role(db, userid, role_list):
173 ''' see if the given user has any roles that appear
174 in the role_list
175 '''
176 for role in iter_roles(db.user.get(userid, 'roles')):
177 if role in iter_roles(role_list):
178 return True
179 return False
182 def check_pgp_sigs(sig, gpgctx, author):
183 ''' Theoretically a PGP message can have several signatures. GPGME
184 returns status on all signatures in a linked list. Walk that
185 linked list looking for the author's signature
186 '''
187 for sig in gpgh_sigs(sig):
188 key = gpgctx.get_key(sig.fpr, False)
189 # we really only care about the signature of the user who
190 # submitted the email
191 if key and (author in gpgh_key_getall(key, 'email')):
192 if sig.summary & pyme.gpgme.GPGME_SIGSUM_VALID:
193 return True
194 else:
195 # try to narrow down the actual problem to give a more useful
196 # message in our bounce
197 if sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_MISSING:
198 raise MailUsageError, \
199 _("Message signed with unknown key: %s") % sig.fpr
200 elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_EXPIRED:
201 raise MailUsageError, \
202 _("Message signed with an expired key: %s") % sig.fpr
203 elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_REVOKED:
204 raise MailUsageError, \
205 _("Message signed with a revoked key: %s") % sig.fpr
206 else:
207 raise MailUsageError, \
208 _("Invalid PGP signature detected.")
210 # we couldn't find a key belonging to the author of the email
211 raise MailUsageError, _("Message signed with unknown key: %s") % sig.fpr
213 class Message(mimetools.Message):
214 ''' subclass mimetools.Message so we can retrieve the parts of the
215 message...
216 '''
217 def getpart(self):
218 ''' Get a single part of a multipart message and return it as a new
219 Message instance.
220 '''
221 boundary = self.getparam('boundary')
222 mid, end = '--'+boundary, '--'+boundary+'--'
223 s = cStringIO.StringIO()
224 while 1:
225 line = self.fp.readline()
226 if not line:
227 break
228 if line.strip() in (mid, end):
229 # according to rfc 1431 the preceding line ending is part of
230 # the boundary so we need to strip that
231 length = s.tell()
232 s.seek(-2, 1)
233 lineending = s.read(2)
234 if lineending == '\r\n':
235 s.truncate(length - 2)
236 elif lineending[1] in ('\r', '\n'):
237 s.truncate(length - 1)
238 else:
239 raise ValueError('Unknown line ending in message.')
240 break
241 s.write(line)
242 if not s.getvalue().strip():
243 return None
244 s.seek(0)
245 return Message(s)
247 def getparts(self):
248 """Get all parts of this multipart message."""
249 # skip over the intro to the first boundary
250 self.fp.seek(0)
251 self.getpart()
253 # accumulate the other parts
254 parts = []
255 while 1:
256 part = self.getpart()
257 if part is None:
258 break
259 parts.append(part)
260 return parts
262 def getheader(self, name, default=None):
263 hdr = mimetools.Message.getheader(self, name, default)
264 if hdr:
265 hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
266 return rfc2822.decode_header(hdr)
268 def getname(self):
269 """Find an appropriate name for this message."""
270 if self.gettype() == 'message/rfc822':
271 # handle message/rfc822 specially - the name should be
272 # the subject of the actual e-mail embedded here
273 self.fp.seek(0)
274 name = Message(self.fp).getheader('subject')
275 else:
276 # try name on Content-Type
277 name = self.getparam('name')
278 if not name:
279 disp = self.getheader('content-disposition', None)
280 if disp:
281 name = getparam(disp, 'filename')
283 if name:
284 return name.strip()
286 def getbody(self):
287 """Get the decoded message body."""
288 self.rewindbody()
289 encoding = self.getencoding()
290 data = None
291 if encoding == 'base64':
292 # BUG: is base64 really used for text encoding or
293 # are we inserting zip files here.
294 data = binascii.a2b_base64(self.fp.read())
295 elif encoding == 'quoted-printable':
296 # the quopri module wants to work with files
297 decoded = cStringIO.StringIO()
298 quopri.decode(self.fp, decoded)
299 data = decoded.getvalue()
300 elif encoding == 'uuencoded':
301 data = binascii.a2b_uu(self.fp.read())
302 else:
303 # take it as text
304 data = self.fp.read()
306 # Encode message to unicode
307 charset = rfc2822.unaliasCharset(self.getparam("charset"))
308 if charset:
309 # Do conversion only if charset specified - handle
310 # badly-specified charsets
311 edata = unicode(data, charset, 'replace').encode('utf-8')
312 # Convert from dos eol to unix
313 edata = edata.replace('\r\n', '\n')
314 else:
315 # Leave message content as is
316 edata = data
318 return edata
320 # General multipart handling:
321 # Take the first text/plain part, anything else is considered an
322 # attachment.
323 # multipart/mixed:
324 # Multiple "unrelated" parts.
325 # multipart/Alternative (rfc 1521):
326 # Like multipart/mixed, except that we'd only want one of the
327 # alternatives. Generally a top-level part from MUAs sending HTML
328 # mail - there will be a text/plain version.
329 # multipart/signed (rfc 1847):
330 # The control information is carried in the second of the two
331 # required body parts.
332 # ACTION: Default, so if content is text/plain we get it.
333 # multipart/encrypted (rfc 1847):
334 # The control information is carried in the first of the two
335 # required body parts.
336 # ACTION: Not handleable as the content is encrypted.
337 # multipart/related (rfc 1872, 2112, 2387):
338 # The Multipart/Related content-type addresses the MIME
339 # representation of compound objects, usually HTML mail with embedded
340 # images. Usually appears as an alternative.
341 # ACTION: Default, if we must.
342 # multipart/report (rfc 1892):
343 # e.g. mail system delivery status reports.
344 # ACTION: Default. Could be ignored or used for Delivery Notification
345 # flagging.
346 # multipart/form-data:
347 # For web forms only.
349 def extract_content(self, parent_type=None, ignore_alternatives = False):
350 """Extract the body and the attachments recursively.
352 If the content is hidden inside a multipart/alternative part,
353 we use the *last* text/plain part of the *first*
354 multipart/alternative in the whole message.
355 """
356 content_type = self.gettype()
357 content = None
358 attachments = []
360 if content_type == 'text/plain':
361 content = self.getbody()
362 elif content_type[:10] == 'multipart/':
363 content_found = bool (content)
364 ig = ignore_alternatives and not content_found
365 for part in self.getparts():
366 new_content, new_attach = part.extract_content(content_type,
367 not content and ig)
369 # If we haven't found a text/plain part yet, take this one,
370 # otherwise make it an attachment.
371 if not content:
372 content = new_content
373 cpart = part
374 elif new_content:
375 if content_found or content_type != 'multipart/alternative':
376 attachments.append(part.text_as_attachment())
377 else:
378 # if we have found a text/plain in the current
379 # multipart/alternative and find another one, we
380 # use the first as an attachment (if configured)
381 # and use the second one because rfc 2046, sec.
382 # 5.1.4. specifies that later parts are better
383 # (thanks to Philipp Gortan for pointing this
384 # out)
385 attachments.append(cpart.text_as_attachment())
386 content = new_content
387 cpart = part
389 attachments.extend(new_attach)
390 if ig and content_type == 'multipart/alternative' and content:
391 attachments = []
392 elif (parent_type == 'multipart/signed' and
393 content_type == 'application/pgp-signature'):
394 # ignore it so it won't be saved as an attachment
395 pass
396 else:
397 attachments.append(self.as_attachment())
398 return content, attachments
400 def text_as_attachment(self):
401 """Return first text/plain part as Message"""
402 if not self.gettype().startswith ('multipart/'):
403 return self.as_attachment()
404 for part in self.getparts():
405 content_type = part.gettype()
406 if content_type == 'text/plain':
407 return part.as_attachment()
408 elif content_type.startswith ('multipart/'):
409 p = part.text_as_attachment()
410 if p:
411 return p
412 return None
414 def as_attachment(self):
415 """Return this message as an attachment."""
416 return (self.getname(), self.gettype(), self.getbody())
418 def pgp_signed(self):
419 ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
420 '''
421 return self.gettype() == 'multipart/signed' \
422 and self.typeheader.find('protocol="application/pgp-signature"') != -1
424 def pgp_encrypted(self):
425 ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
426 '''
427 return self.gettype() == 'multipart/encrypted' \
428 and self.typeheader.find('protocol="application/pgp-encrypted"') != -1
430 def decrypt(self, author):
431 ''' decrypt an OpenPGP MIME message
432 This message must be signed as well as encrypted using the "combined"
433 method. The decrypted contents are returned as a new message.
434 '''
435 (hdr, msg) = self.getparts()
436 # According to the RFC 3156 encrypted mail must have exactly two parts.
437 # The first part contains the control information. Let's verify that
438 # the message meets the RFC before we try to decrypt it.
439 if hdr.getbody() != 'Version: 1' or hdr.gettype() != 'application/pgp-encrypted':
440 raise MailUsageError, \
441 _("Unknown multipart/encrypted version.")
443 context = pyme.core.Context()
444 ciphertext = pyme.core.Data(msg.getbody())
445 plaintext = pyme.core.Data()
447 result = context.op_decrypt_verify(ciphertext, plaintext)
449 if result:
450 raise MailUsageError, _("Unable to decrypt your message.")
452 # we've decrypted it but that just means they used our public
453 # key to send it to us. now check the signatures to see if it
454 # was signed by someone we trust
455 result = context.op_verify_result()
456 check_pgp_sigs(result.signatures, context, author)
458 plaintext.seek(0,0)
459 # pyme.core.Data implements a seek method with a different signature
460 # than roundup can handle. So we'll put the data in a container that
461 # the Message class can work with.
462 c = cStringIO.StringIO()
463 c.write(plaintext.read())
464 c.seek(0)
465 return Message(c)
467 def verify_signature(self, author):
468 ''' verify the signature of an OpenPGP MIME message
469 This only handles detached signatures. Old style
470 PGP mail (i.e. '-----BEGIN PGP SIGNED MESSAGE----')
471 is archaic and not supported :)
472 '''
473 # we don't check the micalg parameter...gpgme seems to
474 # figure things out on its own
475 (msg, sig) = self.getparts()
477 if sig.gettype() != 'application/pgp-signature':
478 raise MailUsageError, \
479 _("No PGP signature found in message.")
481 context = pyme.core.Context()
482 # msg.getbody() is skipping over some headers that are
483 # required to be present for verification to succeed so
484 # we'll do this by hand
485 msg.fp.seek(0)
486 # according to rfc 3156 the data "MUST first be converted
487 # to its content-type specific canonical form. For
488 # text/plain this means conversion to an appropriate
489 # character set and conversion of line endings to the
490 # canonical <CR><LF> sequence."
491 # TODO: what about character set conversion?
492 canonical_msg = re.sub('(?<!\r)\n', '\r\n', msg.fp.read())
493 msg_data = pyme.core.Data(canonical_msg)
494 sig_data = pyme.core.Data(sig.getbody())
496 context.op_verify(sig_data, msg_data, None)
498 # check all signatures for validity
499 result = context.op_verify_result()
500 check_pgp_sigs(result.signatures, context, author)
502 class MailGW:
504 def __init__(self, instance, db, arguments=()):
505 self.instance = instance
506 self.db = db
507 self.arguments = arguments
508 self.default_class = None
509 for option, value in self.arguments:
510 if option == '-c':
511 self.default_class = value.strip()
513 self.mailer = Mailer(instance.config)
514 self.logger = logging.getLogger('mailgw')
516 # should we trap exceptions (normal usage) or pass them through
517 # (for testing)
518 self.trapExceptions = 1
520 def do_pipe(self):
521 """ Read a message from standard input and pass it to the mail handler.
523 Read into an internal structure that we can seek on (in case
524 there's an error).
526 XXX: we may want to read this into a temporary file instead...
527 """
528 s = cStringIO.StringIO()
529 s.write(sys.stdin.read())
530 s.seek(0)
531 self.main(s)
532 return 0
534 def do_mailbox(self, filename):
535 """ Read a series of messages from the specified unix mailbox file and
536 pass each to the mail handler.
537 """
538 # open the spool file and lock it
539 import fcntl
540 # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
541 if hasattr(fcntl, 'LOCK_EX'):
542 FCNTL = fcntl
543 else:
544 import FCNTL
545 f = open(filename, 'r+')
546 fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
548 # handle and clear the mailbox
549 try:
550 from mailbox import UnixMailbox
551 mailbox = UnixMailbox(f, factory=Message)
552 # grab one message
553 message = mailbox.next()
554 while message:
555 # handle this message
556 self.handle_Message(message)
557 message = mailbox.next()
558 # nuke the file contents
559 os.ftruncate(f.fileno(), 0)
560 except:
561 import traceback
562 traceback.print_exc()
563 return 1
564 fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
565 return 0
567 def do_imap(self, server, user='', password='', mailbox='', ssl=0):
568 ''' Do an IMAP connection
569 '''
570 import getpass, imaplib, socket
571 try:
572 if not user:
573 user = raw_input('User: ')
574 if not password:
575 password = getpass.getpass()
576 except (KeyboardInterrupt, EOFError):
577 # Ctrl C or D maybe also Ctrl Z under Windows.
578 print "\nAborted by user."
579 return 1
580 # open a connection to the server and retrieve all messages
581 try:
582 if ssl:
583 self.logger.debug('Trying server %r with ssl'%server)
584 server = imaplib.IMAP4_SSL(server)
585 else:
586 self.logger.debug('Trying server %r without ssl'%server)
587 server = imaplib.IMAP4(server)
588 except (imaplib.IMAP4.error, socket.error, socket.sslerror):
589 self.logger.exception('IMAP server error')
590 return 1
592 try:
593 server.login(user, password)
594 except imaplib.IMAP4.error, e:
595 self.logger.exception('IMAP login failure')
596 return 1
598 try:
599 if not mailbox:
600 (typ, data) = server.select()
601 else:
602 (typ, data) = server.select(mailbox=mailbox)
603 if typ != 'OK':
604 self.logger.error('Failed to get mailbox %r: %s'%(mailbox,
605 data))
606 return 1
607 try:
608 numMessages = int(data[0])
609 except ValueError, value:
610 self.logger.error('Invalid message count from mailbox %r'%
611 data[0])
612 return 1
613 for i in range(1, numMessages+1):
614 (typ, data) = server.fetch(str(i), '(RFC822)')
616 # mark the message as deleted.
617 server.store(str(i), '+FLAGS', r'(\Deleted)')
619 # process the message
620 s = cStringIO.StringIO(data[0][1])
621 s.seek(0)
622 self.handle_Message(Message(s))
623 server.close()
624 finally:
625 try:
626 server.expunge()
627 except:
628 pass
629 server.logout()
631 return 0
634 def do_apop(self, server, user='', password='', ssl=False):
635 ''' Do authentication POP
636 '''
637 self._do_pop(server, user, password, True, ssl)
639 def do_pop(self, server, user='', password='', ssl=False):
640 ''' Do plain POP
641 '''
642 self._do_pop(server, user, password, False, ssl)
644 def _do_pop(self, server, user, password, apop, ssl):
645 '''Read a series of messages from the specified POP server.
646 '''
647 import getpass, poplib, socket
648 try:
649 if not user:
650 user = raw_input('User: ')
651 if not password:
652 password = getpass.getpass()
653 except (KeyboardInterrupt, EOFError):
654 # Ctrl C or D maybe also Ctrl Z under Windows.
655 print "\nAborted by user."
656 return 1
658 # open a connection to the server and retrieve all messages
659 try:
660 if ssl:
661 klass = poplib.POP3_SSL
662 else:
663 klass = poplib.POP3
664 server = klass(server)
665 except socket.error:
666 self.logger.exception('POP server error')
667 return 1
668 if apop:
669 server.apop(user, password)
670 else:
671 server.user(user)
672 server.pass_(password)
673 numMessages = len(server.list()[1])
674 for i in range(1, numMessages+1):
675 # retr: returns
676 # [ pop response e.g. '+OK 459 octets',
677 # [ array of message lines ],
678 # number of octets ]
679 lines = server.retr(i)[1]
680 s = cStringIO.StringIO('\n'.join(lines))
681 s.seek(0)
682 self.handle_Message(Message(s))
683 # delete the message
684 server.dele(i)
686 # quit the server to commit changes.
687 server.quit()
688 return 0
690 def main(self, fp):
691 ''' fp - the file from which to read the Message.
692 '''
693 return self.handle_Message(Message(fp))
695 def handle_Message(self, message):
696 """Handle an RFC822 Message
698 Handle the Message object by calling handle_message() and then cope
699 with any errors raised by handle_message.
700 This method's job is to make that call and handle any
701 errors in a sane manner. It should be replaced if you wish to
702 handle errors in a different manner.
703 """
704 # in some rare cases, a particularly stuffed-up e-mail will make
705 # its way into here... try to handle it gracefully
707 sendto = message.getaddrlist('resent-from')
708 if not sendto:
709 sendto = message.getaddrlist('from')
710 if not sendto:
711 # very bad-looking message - we don't even know who sent it
712 msg = ['Badly formed message from mail gateway. Headers:']
713 msg.extend(message.headers)
714 msg = '\n'.join(map(str, msg))
715 self.logger.error(msg)
716 return
718 msg = 'Handling message'
719 if message.getheader('message-id'):
720 msg += ' (Message-id=%r)'%message.getheader('message-id')
721 self.logger.info(msg)
723 # try normal message-handling
724 if not self.trapExceptions:
725 return self.handle_message(message)
727 # no, we want to trap exceptions
728 try:
729 return self.handle_message(message)
730 except MailUsageHelp:
731 # bounce the message back to the sender with the usage message
732 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
733 m = ['']
734 m.append('\n\nMail Gateway Help\n=================')
735 m.append(fulldoc)
736 self.mailer.bounce_message(message, [sendto[0][1]], m,
737 subject="Mail Gateway Help")
738 except MailUsageError, value:
739 # bounce the message back to the sender with the usage message
740 fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
741 m = ['']
742 m.append(str(value))
743 m.append('\n\nMail Gateway Help\n=================')
744 m.append(fulldoc)
745 self.mailer.bounce_message(message, [sendto[0][1]], m)
746 except Unauthorized, value:
747 # just inform the user that he is not authorized
748 m = ['']
749 m.append(str(value))
750 self.mailer.bounce_message(message, [sendto[0][1]], m)
751 except IgnoreMessage:
752 # do not take any action
753 # this exception is thrown when email should be ignored
754 msg = 'IgnoreMessage raised'
755 if message.getheader('message-id'):
756 msg += ' (Message-id=%r)'%message.getheader('message-id')
757 self.logger.info(msg)
758 return
759 except:
760 msg = 'Exception handling message'
761 if message.getheader('message-id'):
762 msg += ' (Message-id=%r)'%message.getheader('message-id')
763 self.logger.exception(msg)
765 # bounce the message back to the sender with the error message
766 # let the admin know that something very bad is happening
767 m = ['']
768 m.append('An unexpected error occurred during the processing')
769 m.append('of your message. The tracker administrator is being')
770 m.append('notified.\n')
771 self.mailer.bounce_message(message, [sendto[0][1]], m)
773 m.append('----------------')
774 m.append(traceback.format_exc())
775 self.mailer.bounce_message(message, [self.instance.config.ADMIN_EMAIL], m)
777 def handle_message(self, message):
778 ''' message - a Message instance
780 Parse the message as per the module docstring.
781 '''
782 # detect loops
783 if message.getheader('x-roundup-loop', ''):
784 raise IgnoreLoop
786 # handle the subject line
787 subject = message.getheader('subject', '')
788 if not subject:
789 raise MailUsageError, _("""
790 Emails to Roundup trackers must include a Subject: line!
791 """)
793 # detect Precedence: Bulk, or Microsoft Outlook autoreplies
794 if (message.getheader('precedence', '') == 'bulk'
795 or subject.lower().find("autoreply") > 0):
796 raise IgnoreBulk
798 if subject.strip().lower() == 'help':
799 raise MailUsageHelp
801 # config is used many times in this method.
802 # make local variable for easier access
803 config = self.instance.config
805 # determine the sender's address
806 from_list = message.getaddrlist('resent-from')
807 if not from_list:
808 from_list = message.getaddrlist('from')
810 # XXX Don't enable. This doesn't work yet.
811 # "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
812 # handle delivery to addresses like:tracker+issue25@some.dom.ain
813 # use the embedded issue number as our issue
814 # issue_re = config['MAILGW_ISSUE_ADDRESS_RE']
815 # if issue_re:
816 # for header in ['to', 'cc', 'bcc']:
817 # addresses = message.getheader(header, '')
818 # if addresses:
819 # # FIXME, this only finds the first match in the addresses.
820 # issue = re.search(issue_re, addresses, 'i')
821 # if issue:
822 # classname = issue.group('classname')
823 # nodeid = issue.group('nodeid')
824 # break
826 # Matches subjects like:
827 # Re: "[issue1234] title of issue [status=resolved]"
829 # Alias since we need a reference to the original subject for
830 # later use in error messages
831 tmpsubject = subject
833 sd_open, sd_close = config['MAILGW_SUBJECT_SUFFIX_DELIMITERS']
834 delim_open = re.escape(sd_open)
835 if delim_open in '[(': delim_open = '\\' + delim_open
836 delim_close = re.escape(sd_close)
837 if delim_close in '[(': delim_close = '\\' + delim_close
839 matches = dict.fromkeys(['refwd', 'quote', 'classname',
840 'nodeid', 'title', 'args',
841 'argswhole'])
843 # Look for Re: et. al. Used later on for MAILGW_SUBJECT_CONTENT_MATCH
844 re_re = r"(?P<refwd>%s)\s*" % config["MAILGW_REFWD_RE"].pattern
845 m = re.match(re_re, tmpsubject, re.IGNORECASE|re.VERBOSE|re.UNICODE)
846 if m:
847 m = m.groupdict()
848 if m['refwd']:
849 matches.update(m)
850 tmpsubject = tmpsubject[len(m['refwd']):] # Consume Re:
852 # Look for Leading "
853 m = re.match(r'(?P<quote>\s*")', tmpsubject,
854 re.IGNORECASE)
855 if m:
856 matches.update(m.groupdict())
857 tmpsubject = tmpsubject[len(matches['quote']):] # Consume quote
859 has_prefix = re.search(r'^%s(\w+)%s'%(delim_open,
860 delim_close), tmpsubject.strip())
862 class_re = r'%s(?P<classname>(%s))(?P<nodeid>\d+)?%s'%(delim_open,
863 "|".join(self.db.getclasses()), delim_close)
864 # Note: re.search, not re.match as there might be garbage
865 # (mailing list prefix, etc.) before the class identifier
866 m = re.search(class_re, tmpsubject, re.IGNORECASE)
867 if m:
868 matches.update(m.groupdict())
869 # Skip to the end of the class identifier, including any
870 # garbage before it.
872 tmpsubject = tmpsubject[m.end():]
874 # if we've not found a valid classname prefix then force the
875 # scanning to handle there being a leading delimiter
876 title_re = r'(?P<title>%s[^%s]+)'%(
877 not matches['classname'] and '.' or '', delim_open)
878 m = re.match(title_re, tmpsubject.strip(), re.IGNORECASE)
879 if m:
880 matches.update(m.groupdict())
881 tmpsubject = tmpsubject[len(matches['title']):] # Consume title
883 args_re = r'(?P<argswhole>%s(?P<args>.+?)%s)?'%(delim_open,
884 delim_close)
885 m = re.search(args_re, tmpsubject.strip(), re.IGNORECASE|re.VERBOSE)
886 if m:
887 matches.update(m.groupdict())
889 # figure subject line parsing modes
890 pfxmode = config['MAILGW_SUBJECT_PREFIX_PARSING']
891 sfxmode = config['MAILGW_SUBJECT_SUFFIX_PARSING']
893 # check for registration OTK
894 # or fallback on the default class
895 if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
896 otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
897 otk = otk_re.search(matches['title'] or '')
898 if otk:
899 self.db.confirm_registration(otk.group('otk'))
900 subject = 'Your registration to %s is complete' % \
901 config['TRACKER_NAME']
902 sendto = [from_list[0][1]]
903 self.mailer.standard_message(sendto, subject, '')
904 return
906 # get the classname
907 if pfxmode == 'none':
908 classname = None
909 else:
910 classname = matches['classname']
912 if not classname and has_prefix and pfxmode == 'strict':
913 raise MailUsageError, _("""
914 The message you sent to roundup did not contain a properly formed subject
915 line. The subject must contain a class name or designator to indicate the
916 'topic' of the message. For example:
917 Subject: [issue] This is a new issue
918 - this will create a new issue in the tracker with the title 'This is
919 a new issue'.
920 Subject: [issue1234] This is a followup to issue 1234
921 - this will append the message's contents to the existing issue 1234
922 in the tracker.
924 Subject was: '%(subject)s'
925 """) % locals()
927 # try to get the class specified - if "loose" or "none" then fall
928 # back on the default
929 attempts = []
930 if classname:
931 attempts.append(classname)
933 if self.default_class:
934 attempts.append(self.default_class)
935 else:
936 attempts.append(config['MAILGW_DEFAULT_CLASS'])
938 # first valid class name wins
939 cl = None
940 for trycl in attempts:
941 try:
942 cl = self.db.getclass(trycl)
943 classname = trycl
944 break
945 except KeyError:
946 pass
948 if not cl:
949 validname = ', '.join(self.db.getclasses())
950 if classname:
951 raise MailUsageError, _("""
952 The class name you identified in the subject line ("%(classname)s") does
953 not exist in the database.
955 Valid class names are: %(validname)s
956 Subject was: "%(subject)s"
957 """) % locals()
958 else:
959 raise MailUsageError, _("""
960 You did not identify a class name in the subject line and there is no
961 default set for this tracker. The subject must contain a class name or
962 designator to indicate the 'topic' of the message. For example:
963 Subject: [issue] This is a new issue
964 - this will create a new issue in the tracker with the title 'This is
965 a new issue'.
966 Subject: [issue1234] This is a followup to issue 1234
967 - this will append the message's contents to the existing issue 1234
968 in the tracker.
970 Subject was: '%(subject)s'
971 """) % locals()
973 # get the optional nodeid
974 if pfxmode == 'none':
975 nodeid = None
976 else:
977 nodeid = matches['nodeid']
979 # try in-reply-to to match the message if there's no nodeid
980 inreplyto = message.getheader('in-reply-to') or ''
981 if nodeid is None and inreplyto:
982 l = self.db.getclass('msg').stringFind(messageid=inreplyto)
983 if l:
984 nodeid = cl.filter(None, {'messages':l})[0]
986 # title is optional too
987 title = matches['title']
988 if title:
989 title = title.strip()
990 else:
991 title = ''
993 # strip off the quotes that dumb emailers put around the subject, like
994 # Re: "[issue1] bla blah"
995 if matches['quote'] and title.endswith('"'):
996 title = title[:-1]
998 # but we do need either a title or a nodeid...
999 if nodeid is None and not title:
1000 raise MailUsageError, _("""
1001 I cannot match your message to a node in the database - you need to either
1002 supply a full designator (with number, eg "[issue123]") or keep the
1003 previous subject title intact so I can match that.
1005 Subject was: "%(subject)s"
1006 """) % locals()
1008 # If there's no nodeid, check to see if this is a followup and
1009 # maybe someone's responded to the initial mail that created an
1010 # entry. Try to find the matching nodes with the same title, and
1011 # use the _last_ one matched (since that'll _usually_ be the most
1012 # recent...). The subject_content_match config may specify an
1013 # additional restriction based on the matched node's creation or
1014 # activity.
1015 tmatch_mode = config['MAILGW_SUBJECT_CONTENT_MATCH']
1016 if tmatch_mode != 'never' and nodeid is None and matches['refwd']:
1017 l = cl.stringFind(title=title)
1018 limit = None
1019 if (tmatch_mode.startswith('creation') or
1020 tmatch_mode.startswith('activity')):
1021 limit, interval = tmatch_mode.split(' ', 1)
1022 threshold = date.Date('.') - date.Interval(interval)
1023 for id in l:
1024 if limit:
1025 if threshold < cl.get(id, limit):
1026 nodeid = id
1027 else:
1028 nodeid = id
1030 # if a nodeid was specified, make sure it's valid
1031 if nodeid is not None and not cl.hasnode(nodeid):
1032 if pfxmode == 'strict':
1033 raise MailUsageError, _("""
1034 The node specified by the designator in the subject of your message
1035 ("%(nodeid)s") does not exist.
1037 Subject was: "%(subject)s"
1038 """) % locals()
1039 else:
1040 title = subject
1041 nodeid = None
1043 # Handle the arguments specified by the email gateway command line.
1044 # We do this by looping over the list of self.arguments looking for
1045 # a -C to tell us what class then the -S setting string.
1046 msg_props = {}
1047 user_props = {}
1048 file_props = {}
1049 issue_props = {}
1050 # so, if we have any arguments, use them
1051 if self.arguments:
1052 current_class = 'msg'
1053 for option, propstring in self.arguments:
1054 if option in ( '-C', '--class'):
1055 current_class = propstring.strip()
1056 # XXX this is not flexible enough.
1057 # we should chect for subclasses of these classes,
1058 # not for the class name...
1059 if current_class not in ('msg', 'file', 'user', 'issue'):
1060 mailadmin = config['ADMIN_EMAIL']
1061 raise MailUsageError, _("""
1062 The mail gateway is not properly set up. Please contact
1063 %(mailadmin)s and have them fix the incorrect class specified as:
1064 %(current_class)s
1065 """) % locals()
1066 if option in ('-S', '--set'):
1067 if current_class == 'issue' :
1068 errors, issue_props = setPropArrayFromString(self,
1069 cl, propstring.strip(), nodeid)
1070 elif current_class == 'file' :
1071 temp_cl = self.db.getclass('file')
1072 errors, file_props = setPropArrayFromString(self,
1073 temp_cl, propstring.strip())
1074 elif current_class == 'msg' :
1075 temp_cl = self.db.getclass('msg')
1076 errors, msg_props = setPropArrayFromString(self,
1077 temp_cl, propstring.strip())
1078 elif current_class == 'user' :
1079 temp_cl = self.db.getclass('user')
1080 errors, user_props = setPropArrayFromString(self,
1081 temp_cl, propstring.strip())
1082 if errors:
1083 mailadmin = config['ADMIN_EMAIL']
1084 raise MailUsageError, _("""
1085 The mail gateway is not properly set up. Please contact
1086 %(mailadmin)s and have them fix the incorrect properties:
1087 %(errors)s
1088 """) % locals()
1090 #
1091 # handle the users
1092 #
1093 # Don't create users if anonymous isn't allowed to register
1094 create = 1
1095 anonid = self.db.user.lookup('anonymous')
1096 if not (self.db.security.hasPermission('Create', anonid, 'user')
1097 and self.db.security.hasPermission('Email Access', anonid)):
1098 create = 0
1100 # ok, now figure out who the author is - create a new user if the
1101 # "create" flag is true
1102 author = uidFromAddress(self.db, from_list[0], create=create)
1104 # if we're not recognised, and we don't get added as a user, then we
1105 # must be anonymous
1106 if not author:
1107 author = anonid
1109 # make sure the author has permission to use the email interface
1110 if not self.db.security.hasPermission('Email Access', author):
1111 if author == anonid:
1112 # we're anonymous and we need to be a registered user
1113 from_address = from_list[0][1]
1114 registration_info = ""
1115 if self.db.security.hasPermission('Web Access', author) and \
1116 self.db.security.hasPermission('Create', anonid, 'user'):
1117 tracker_web = self.instance.config.TRACKER_WEB
1118 registration_info = """ Please register at:
1120 %(tracker_web)suser?template=register
1122 ...before sending mail to the tracker.""" % locals()
1124 raise Unauthorized, _("""
1125 You are not a registered user.%(registration_info)s
1127 Unknown address: %(from_address)s
1128 """) % locals()
1129 else:
1130 # we're registered and we're _still_ not allowed access
1131 raise Unauthorized, _(
1132 'You are not permitted to access this tracker.')
1134 # make sure they're allowed to edit or create this class of information
1135 if nodeid:
1136 if not self.db.security.hasPermission('Edit', author, classname,
1137 itemid=nodeid):
1138 raise Unauthorized, _(
1139 'You are not permitted to edit %(classname)s.') % locals()
1140 else:
1141 if not self.db.security.hasPermission('Create', author, classname):
1142 raise Unauthorized, _(
1143 'You are not permitted to create %(classname)s.'
1144 ) % locals()
1146 # the author may have been created - make sure the change is
1147 # committed before we reopen the database
1148 self.db.commit()
1150 # set the database user as the author
1151 username = self.db.user.get(author, 'username')
1152 self.db.setCurrentUser(username)
1154 # re-get the class with the new database connection
1155 cl = self.db.getclass(classname)
1157 # now update the recipients list
1158 recipients = []
1159 tracker_email = config['TRACKER_EMAIL'].lower()
1160 for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
1161 r = recipient[1].strip().lower()
1162 if r == tracker_email or not r:
1163 continue
1165 # look up the recipient - create if necessary (and we're
1166 # allowed to)
1167 recipient = uidFromAddress(self.db, recipient, create, **user_props)
1169 # if all's well, add the recipient to the list
1170 if recipient:
1171 recipients.append(recipient)
1173 #
1174 # handle the subject argument list
1175 #
1176 # figure what the properties of this Class are
1177 properties = cl.getprops()
1178 props = {}
1179 args = matches['args']
1180 argswhole = matches['argswhole']
1181 if args:
1182 if sfxmode == 'none':
1183 title += ' ' + argswhole
1184 else:
1185 errors, props = setPropArrayFromString(self, cl, args, nodeid)
1186 # handle any errors parsing the argument list
1187 if errors:
1188 if sfxmode == 'strict':
1189 errors = '\n- '.join(map(str, errors))
1190 raise MailUsageError, _("""
1191 There were problems handling your subject line argument list:
1192 - %(errors)s
1194 Subject was: "%(subject)s"
1195 """) % locals()
1196 else:
1197 title += ' ' + argswhole
1200 # set the issue title to the subject
1201 title = title.strip()
1202 if (title and properties.has_key('title') and not
1203 issue_props.has_key('title')):
1204 issue_props['title'] = title
1206 #
1207 # handle message-id and in-reply-to
1208 #
1209 messageid = message.getheader('message-id')
1210 # generate a messageid if there isn't one
1211 if not messageid:
1212 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
1213 classname, nodeid, config['MAIL_DOMAIN'])
1215 # if they've enabled PGP processing then verify the signature
1216 # or decrypt the message
1218 # if PGP_ROLES is specified the user must have a Role in the list
1219 # or we will skip PGP processing
1220 def pgp_role():
1221 if self.instance.config.PGP_ROLES:
1222 return user_has_role(self.db, author,
1223 self.instance.config.PGP_ROLES)
1224 else:
1225 return True
1227 if self.instance.config.PGP_ENABLE and pgp_role():
1228 assert pyme, 'pyme is not installed'
1229 # signed/encrypted mail must come from the primary address
1230 author_address = self.db.user.get(author, 'address')
1231 if self.instance.config.PGP_HOMEDIR:
1232 os.environ['GNUPGHOME'] = self.instance.config.PGP_HOMEDIR
1233 if message.pgp_signed():
1234 message.verify_signature(author_address)
1235 elif message.pgp_encrypted():
1236 # replace message with the contents of the decrypted
1237 # message for content extraction
1238 # TODO: encrypted message handling is far from perfect
1239 # bounces probably include the decrypted message, for
1240 # instance :(
1241 message = message.decrypt(author_address)
1242 else:
1243 raise MailUsageError, _("""
1244 This tracker has been configured to require all email be PGP signed or
1245 encrypted.""")
1246 # now handle the body - find the message
1247 ig = self.instance.config.MAILGW_IGNORE_ALTERNATIVES
1248 content, attachments = message.extract_content(ignore_alternatives = ig)
1249 if content is None:
1250 raise MailUsageError, _("""
1251 Roundup requires the submission to be plain text. The message parser could
1252 not find a text/plain part to use.
1253 """)
1255 # parse the body of the message, stripping out bits as appropriate
1256 summary, content = parseContent(content, config=config)
1257 content = content.strip()
1259 #
1260 # handle the attachments
1261 #
1262 if properties.has_key('files'):
1263 files = []
1264 for (name, mime_type, data) in attachments:
1265 if not self.db.security.hasPermission('Create', author, 'file'):
1266 raise Unauthorized, _(
1267 'You are not permitted to create files.')
1268 if not name:
1269 name = "unnamed"
1270 try:
1271 fileid = self.db.file.create(type=mime_type, name=name,
1272 content=data, **file_props)
1273 except exceptions.Reject:
1274 pass
1275 else:
1276 files.append(fileid)
1277 # attach the files to the issue
1278 if not self.db.security.hasPermission('Edit', author,
1279 classname, 'files'):
1280 raise Unauthorized, _(
1281 'You are not permitted to add files to %(classname)s.'
1282 ) % locals()
1284 if nodeid:
1285 # extend the existing files list
1286 fileprop = cl.get(nodeid, 'files')
1287 fileprop.extend(files)
1288 props['files'] = fileprop
1289 else:
1290 # pre-load the files list
1291 props['files'] = files
1293 #
1294 # create the message if there's a message body (content)
1295 #
1296 if (content and properties.has_key('messages')):
1297 if not self.db.security.hasPermission('Create', author, 'msg'):
1298 raise Unauthorized, _(
1299 'You are not permitted to create messages.')
1301 try:
1302 message_id = self.db.msg.create(author=author,
1303 recipients=recipients, date=date.Date('.'),
1304 summary=summary, content=content, files=files,
1305 messageid=messageid, inreplyto=inreplyto, **msg_props)
1306 except exceptions.Reject, error:
1307 raise MailUsageError, _("""
1308 Mail message was rejected by a detector.
1309 %(error)s
1310 """) % locals()
1311 # attach the message to the node
1312 if not self.db.security.hasPermission('Edit', author,
1313 classname, 'messages'):
1314 raise Unauthorized, _(
1315 'You are not permitted to add messages to %(classname)s.'
1316 ) % locals()
1318 if nodeid:
1319 # add the message to the node's list
1320 messages = cl.get(nodeid, 'messages')
1321 messages.append(message_id)
1322 props['messages'] = messages
1323 else:
1324 # pre-load the messages list
1325 props['messages'] = [message_id]
1327 #
1328 # perform the node change / create
1329 #
1330 try:
1331 # merge the command line props defined in issue_props into
1332 # the props dictionary because function(**props, **issue_props)
1333 # is a syntax error.
1334 for prop in issue_props.keys() :
1335 if not props.has_key(prop) :
1336 props[prop] = issue_props[prop]
1338 # Check permissions for each property
1339 for prop in props.keys():
1340 if not self.db.security.hasPermission('Edit', author,
1341 classname, prop):
1342 raise Unauthorized, _('You are not permitted to edit '
1343 'property %(prop)s of class %(classname)s.') % locals()
1345 if nodeid:
1346 cl.set(nodeid, **props)
1347 else:
1348 nodeid = cl.create(**props)
1349 except (TypeError, IndexError, ValueError, exceptions.Reject), message:
1350 raise MailUsageError, _("""
1351 There was a problem with the message you sent:
1352 %(message)s
1353 """) % locals()
1355 # commit the changes to the DB
1356 self.db.commit()
1358 return nodeid
1361 def setPropArrayFromString(self, cl, propString, nodeid=None):
1362 ''' takes string of form prop=value,value;prop2=value
1363 and returns (error, prop[..])
1364 '''
1365 props = {}
1366 errors = []
1367 for prop in string.split(propString, ';'):
1368 # extract the property name and value
1369 try:
1370 propname, value = prop.split('=')
1371 except ValueError, message:
1372 errors.append(_('not of form [arg=value,value,...;'
1373 'arg=value,value,...]'))
1374 return (errors, props)
1375 # convert the value to a hyperdb-usable value
1376 propname = propname.strip()
1377 try:
1378 props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1379 propname, value)
1380 except hyperdb.HyperdbValueError, message:
1381 errors.append(str(message))
1382 return errors, props
1385 def extractUserFromList(userClass, users):
1386 '''Given a list of users, try to extract the first non-anonymous user
1387 and return that user, otherwise return None
1388 '''
1389 if len(users) > 1:
1390 for user in users:
1391 # make sure we don't match the anonymous or admin user
1392 if userClass.get(user, 'username') in ('admin', 'anonymous'):
1393 continue
1394 # first valid match will do
1395 return user
1396 # well, I guess we have no choice
1397 return user[0]
1398 elif users:
1399 return users[0]
1400 return None
1403 def uidFromAddress(db, address, create=1, **user_props):
1404 ''' address is from the rfc822 module, and therefore is (name, addr)
1406 user is created if they don't exist in the db already
1407 user_props may supply additional user information
1408 '''
1409 (realname, address) = address
1411 # try a straight match of the address
1412 user = extractUserFromList(db.user, db.user.stringFind(address=address))
1413 if user is not None:
1414 return user
1416 # try the user alternate addresses if possible
1417 props = db.user.getprops()
1418 if props.has_key('alternate_addresses'):
1419 users = db.user.filter(None, {'alternate_addresses': address})
1420 user = extractUserFromList(db.user, users)
1421 if user is not None:
1422 return user
1424 # try to match the username to the address (for local
1425 # submissions where the address is empty)
1426 user = extractUserFromList(db.user, db.user.stringFind(username=address))
1428 # couldn't match address or username, so create a new user
1429 if create:
1430 # generate a username
1431 if '@' in address:
1432 username = address.split('@')[0]
1433 else:
1434 username = address
1435 trying = username
1436 n = 0
1437 while 1:
1438 try:
1439 # does this username exist already?
1440 db.user.lookup(trying)
1441 except KeyError:
1442 break
1443 n += 1
1444 trying = username + str(n)
1446 # create!
1447 try:
1448 return db.user.create(username=trying, address=address,
1449 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1450 password=password.Password(password.generatePassword()),
1451 **user_props)
1452 except exceptions.Reject:
1453 return 0
1454 else:
1455 return 0
1457 def parseContent(content, keep_citations=None, keep_body=None, config=None):
1458 """Parse mail message; return message summary and stripped content
1460 The message body is divided into sections by blank lines.
1461 Sections where the second and all subsequent lines begin with a ">"
1462 or "|" character are considered "quoting sections". The first line of
1463 the first non-quoting section becomes the summary of the message.
1465 Arguments:
1467 keep_citations: declared for backward compatibility.
1468 If omitted or None, use config["MAILGW_KEEP_QUOTED_TEXT"]
1470 keep_body: declared for backward compatibility.
1471 If omitted or None, use config["MAILGW_LEAVE_BODY_UNCHANGED"]
1473 config: tracker configuration object.
1474 If omitted or None, use default configuration.
1476 """
1477 if config is None:
1478 config = configuration.CoreConfig()
1479 if keep_citations is None:
1480 keep_citations = config["MAILGW_KEEP_QUOTED_TEXT"]
1481 if keep_body is None:
1482 keep_body = config["MAILGW_LEAVE_BODY_UNCHANGED"]
1483 eol = config["MAILGW_EOL_RE"]
1484 signature = config["MAILGW_SIGN_RE"]
1485 original_msg = config["MAILGW_ORIGMSG_RE"]
1487 # strip off leading carriage-returns / newlines
1488 i = 0
1489 for i in range(len(content)):
1490 if content[i] not in '\r\n':
1491 break
1492 if i > 0:
1493 sections = config["MAILGW_BLANKLINE_RE"].split(content[i:])
1494 else:
1495 sections = config["MAILGW_BLANKLINE_RE"].split(content)
1497 # extract out the summary from the message
1498 summary = ''
1499 l = []
1500 for section in sections:
1501 #section = section.strip()
1502 if not section:
1503 continue
1504 lines = eol.split(section)
1505 if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1506 lines[1] and lines[1][0] in '>|'):
1507 # see if there's a response somewhere inside this section (ie.
1508 # no blank line between quoted message and response)
1509 for line in lines[1:]:
1510 if line and line[0] not in '>|':
1511 break
1512 else:
1513 # we keep quoted bits if specified in the config
1514 if keep_citations:
1515 l.append(section)
1516 continue
1517 # keep this section - it has reponse stuff in it
1518 lines = lines[lines.index(line):]
1519 section = '\n'.join(lines)
1520 # and while we're at it, use the first non-quoted bit as
1521 # our summary
1522 summary = section
1524 if not summary:
1525 # if we don't have our summary yet use the first line of this
1526 # section
1527 summary = section
1528 elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1529 # lose any signature
1530 break
1531 elif original_msg.match(lines[0]):
1532 # ditch the stupid Outlook quoting of the entire original message
1533 break
1535 # and add the section to the output
1536 l.append(section)
1538 # figure the summary - find the first sentence-ending punctuation or the
1539 # first whole line, whichever is longest
1540 sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1541 if sentence:
1542 sentence = sentence.group(1)
1543 else:
1544 sentence = ''
1545 first = eol.split(summary)[0]
1546 summary = max(sentence, first)
1548 # Now reconstitute the message content minus the bits we don't care
1549 # about.
1550 if not keep_body:
1551 content = '\n\n'.join(l)
1553 return summary, content
1555 # vim: set filetype=python sts=4 sw=4 et si :