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