Code

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