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