Code

- fix some format errors in italian translation
[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, 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, arguments=()):
528         self.instance = instance
529         self.arguments = arguments
530         self.default_class = None
531         for option, value in self.arguments:
532             if option == '-c':
533                 self.default_class = value.strip()
535         self.mailer = Mailer(instance.config)
536         self.logger = logging.getLogger('mailgw')
538         # should we trap exceptions (normal usage) or pass them through
539         # (for testing)
540         self.trapExceptions = 1
542     def do_pipe(self):
543         """ Read a message from standard input and pass it to the mail handler.
545             Read into an internal structure that we can seek on (in case
546             there's an error).
548             XXX: we may want to read this into a temporary file instead...
549         """
550         s = cStringIO.StringIO()
551         s.write(sys.stdin.read())
552         s.seek(0)
553         self.main(s)
554         return 0
556     def do_mailbox(self, filename):
557         """ Read a series of messages from the specified unix mailbox file and
558             pass each to the mail handler.
559         """
560         # open the spool file and lock it
561         import fcntl
562         # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
563         if hasattr(fcntl, 'LOCK_EX'):
564             FCNTL = fcntl
565         else:
566             import FCNTL
567         f = open(filename, 'r+')
568         fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
570         # handle and clear the mailbox
571         try:
572             from mailbox import UnixMailbox
573             mailbox = UnixMailbox(f, factory=Message)
574             # grab one message
575             message = mailbox.next()
576             while message:
577                 # handle this message
578                 self.handle_Message(message)
579                 message = mailbox.next()
580             # nuke the file contents
581             os.ftruncate(f.fileno(), 0)
582         except:
583             import traceback
584             traceback.print_exc()
585             return 1
586         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
587         return 0
589     def do_imap(self, server, user='', password='', mailbox='', ssl=0):
590         ''' Do an IMAP connection
591         '''
592         import getpass, imaplib, socket
593         try:
594             if not user:
595                 user = raw_input('User: ')
596             if not password:
597                 password = getpass.getpass()
598         except (KeyboardInterrupt, EOFError):
599             # Ctrl C or D maybe also Ctrl Z under Windows.
600             print "\nAborted by user."
601             return 1
602         # open a connection to the server and retrieve all messages
603         try:
604             if ssl:
605                 self.logger.debug('Trying server %r with ssl'%server)
606                 server = imaplib.IMAP4_SSL(server)
607             else:
608                 self.logger.debug('Trying server %r without ssl'%server)
609                 server = imaplib.IMAP4(server)
610         except (imaplib.IMAP4.error, socket.error, socket.sslerror):
611             self.logger.exception('IMAP server error')
612             return 1
614         try:
615             server.login(user, password)
616         except imaplib.IMAP4.error, e:
617             self.logger.exception('IMAP login failure')
618             return 1
620         try:
621             if not mailbox:
622                 (typ, data) = server.select()
623             else:
624                 (typ, data) = server.select(mailbox=mailbox)
625             if typ != 'OK':
626                 self.logger.error('Failed to get mailbox %r: %s'%(mailbox,
627                     data))
628                 return 1
629             try:
630                 numMessages = int(data[0])
631             except ValueError, value:
632                 self.logger.error('Invalid message count from mailbox %r'%
633                     data[0])
634                 return 1
635             for i in range(1, numMessages+1):
636                 (typ, data) = server.fetch(str(i), '(RFC822)')
638                 # mark the message as deleted.
639                 server.store(str(i), '+FLAGS', r'(\Deleted)')
641                 # process the message
642                 s = cStringIO.StringIO(data[0][1])
643                 s.seek(0)
644                 self.handle_Message(Message(s))
645             server.close()
646         finally:
647             try:
648                 server.expunge()
649             except:
650                 pass
651             server.logout()
653         return 0
656     def do_apop(self, server, user='', password='', ssl=False):
657         ''' Do authentication POP
658         '''
659         self._do_pop(server, user, password, True, ssl)
661     def do_pop(self, server, user='', password='', ssl=False):
662         ''' Do plain POP
663         '''
664         self._do_pop(server, user, password, False, ssl)
666     def _do_pop(self, server, user, password, apop, ssl):
667         '''Read a series of messages from the specified POP server.
668         '''
669         import getpass, poplib, socket
670         try:
671             if not user:
672                 user = raw_input('User: ')
673             if not password:
674                 password = getpass.getpass()
675         except (KeyboardInterrupt, EOFError):
676             # Ctrl C or D maybe also Ctrl Z under Windows.
677             print "\nAborted by user."
678             return 1
680         # open a connection to the server and retrieve all messages
681         try:
682             if ssl:
683                 klass = poplib.POP3_SSL
684             else:
685                 klass = poplib.POP3
686             server = klass(server)
687         except socket.error:
688             self.logger.exception('POP server error')
689             return 1
690         if apop:
691             server.apop(user, password)
692         else:
693             server.user(user)
694             server.pass_(password)
695         numMessages = len(server.list()[1])
696         for i in range(1, numMessages+1):
697             # retr: returns
698             # [ pop response e.g. '+OK 459 octets',
699             #   [ array of message lines ],
700             #   number of octets ]
701             lines = server.retr(i)[1]
702             s = cStringIO.StringIO('\n'.join(lines))
703             s.seek(0)
704             self.handle_Message(Message(s))
705             # delete the message
706             server.dele(i)
708         # quit the server to commit changes.
709         server.quit()
710         return 0
712     def main(self, fp):
713         ''' fp - the file from which to read the Message.
714         '''
715         return self.handle_Message(Message(fp))
717     def handle_Message(self, message):
718         """Handle an RFC822 Message
720         Handle the Message object by calling handle_message() and then cope
721         with any errors raised by handle_message.
722         This method's job is to make that call and handle any
723         errors in a sane manner. It should be replaced if you wish to
724         handle errors in a different manner.
725         """
726         # in some rare cases, a particularly stuffed-up e-mail will make
727         # its way into here... try to handle it gracefully
729         sendto = message.getaddrlist('resent-from')
730         if not sendto:
731             sendto = message.getaddrlist('from')
732         if not sendto:
733             # very bad-looking message - we don't even know who sent it
734             msg = ['Badly formed message from mail gateway. Headers:']
735             msg.extend(message.headers)
736             msg = '\n'.join(map(str, msg))
737             self.logger.error(msg)
738             return
740         msg = 'Handling message'
741         if message.getheader('message-id'):
742             msg += ' (Message-id=%r)'%message.getheader('message-id')
743         self.logger.info(msg)
745         # try normal message-handling
746         if not self.trapExceptions:
747             return self.handle_message(message)
749         # no, we want to trap exceptions
750         try:
751             return self.handle_message(message)
752         except MailUsageHelp:
753             # bounce the message back to the sender with the usage message
754             fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
755             m = ['']
756             m.append('\n\nMail Gateway Help\n=================')
757             m.append(fulldoc)
758             self.mailer.bounce_message(message, [sendto[0][1]], m,
759                 subject="Mail Gateway Help")
760         except MailUsageError, value:
761             # bounce the message back to the sender with the usage message
762             fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
763             m = ['']
764             m.append(str(value))
765             m.append('\n\nMail Gateway Help\n=================')
766             m.append(fulldoc)
767             self.mailer.bounce_message(message, [sendto[0][1]], m)
768         except Unauthorized, value:
769             # just inform the user that he is not authorized
770             m = ['']
771             m.append(str(value))
772             self.mailer.bounce_message(message, [sendto[0][1]], m)
773         except IgnoreMessage:
774             # do not take any action
775             # this exception is thrown when email should be ignored
776             msg = 'IgnoreMessage raised'
777             if message.getheader('message-id'):
778                 msg += ' (Message-id=%r)'%message.getheader('message-id')
779             self.logger.info(msg)
780             return
781         except:
782             msg = 'Exception handling message'
783             if message.getheader('message-id'):
784                 msg += ' (Message-id=%r)'%message.getheader('message-id')
785             self.logger.exception(msg)
787             # bounce the message back to the sender with the error message
788             # let the admin know that something very bad is happening
789             m = ['']
790             m.append('An unexpected error occurred during the processing')
791             m.append('of your message. The tracker administrator is being')
792             m.append('notified.\n')
793             self.mailer.bounce_message(message, [sendto[0][1]], m)
795             m.append('----------------')
796             m.append(traceback.format_exc())
797             self.mailer.bounce_message(message, [self.instance.config.ADMIN_EMAIL], m)
799     def handle_message(self, message):
800         ''' message - a Message instance
802         Parse the message as per the module docstring.
803         '''
804         # get database handle for handling one email
805         self.db = self.instance.open ('admin')
806         try:
807             return self._handle_message (message)
808         finally:
809             self.db.close()
811     def _handle_message(self, message):
812         ''' message - a Message instance
814         Parse the message as per the module docstring.
816         The implementation expects an opened database and a try/finally
817         that closes the database.
818         '''
819         # detect loops
820         if message.getheader('x-roundup-loop', ''):
821             raise IgnoreLoop
823         # handle the subject line
824         subject = message.getheader('subject', '')
825         if not subject:
826             raise MailUsageError, _("""
827 Emails to Roundup trackers must include a Subject: line!
828 """)
830         # detect Precedence: Bulk, or Microsoft Outlook autoreplies
831         if (message.getheader('precedence', '') == 'bulk'
832                 or subject.lower().find("autoreply") > 0):
833             raise IgnoreBulk
835         if subject.strip().lower() == 'help':
836             raise MailUsageHelp
838         # config is used many times in this method.
839         # make local variable for easier access
840         config = self.instance.config
842         # determine the sender's address
843         from_list = message.getaddrlist('resent-from')
844         if not from_list:
845             from_list = message.getaddrlist('from')
847         # XXX Don't enable. This doesn't work yet.
848 #  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
849         # handle delivery to addresses like:tracker+issue25@some.dom.ain
850         # use the embedded issue number as our issue
851 #        issue_re = config['MAILGW_ISSUE_ADDRESS_RE']
852 #        if issue_re:
853 #            for header in ['to', 'cc', 'bcc']:
854 #                addresses = message.getheader(header, '')
855 #            if addresses:
856 #              # FIXME, this only finds the first match in the addresses.
857 #                issue = re.search(issue_re, addresses, 'i')
858 #                if issue:
859 #                    classname = issue.group('classname')
860 #                    nodeid = issue.group('nodeid')
861 #                    break
863         # Matches subjects like:
864         # Re: "[issue1234] title of issue [status=resolved]"
866         # Alias since we need a reference to the original subject for
867         # later use in error messages
868         tmpsubject = subject
870         sd_open, sd_close = config['MAILGW_SUBJECT_SUFFIX_DELIMITERS']
871         delim_open = re.escape(sd_open)
872         if delim_open in '[(': delim_open = '\\' + delim_open
873         delim_close = re.escape(sd_close)
874         if delim_close in '[(': delim_close = '\\' + delim_close
876         matches = dict.fromkeys(['refwd', 'quote', 'classname',
877                                  'nodeid', 'title', 'args',
878                                  'argswhole'])
880         # Look for Re: et. al. Used later on for MAILGW_SUBJECT_CONTENT_MATCH
881         re_re = r"(?P<refwd>%s)\s*" % config["MAILGW_REFWD_RE"].pattern
882         m = re.match(re_re, tmpsubject, re.IGNORECASE|re.VERBOSE|re.UNICODE)
883         if m:
884             m = m.groupdict()
885             if m['refwd']:
886                 matches.update(m)
887                 tmpsubject = tmpsubject[len(m['refwd']):] # Consume Re:
889         # Look for Leading "
890         m = re.match(r'(?P<quote>\s*")', tmpsubject,
891                      re.IGNORECASE)
892         if m:
893             matches.update(m.groupdict())
894             tmpsubject = tmpsubject[len(matches['quote']):] # Consume quote
896         has_prefix = re.search(r'^%s(\w+)%s'%(delim_open,
897             delim_close), tmpsubject.strip())
899         class_re = r'%s(?P<classname>(%s))(?P<nodeid>\d+)?%s'%(delim_open,
900             "|".join(self.db.getclasses()), delim_close)
901         # Note: re.search, not re.match as there might be garbage
902         # (mailing list prefix, etc.) before the class identifier
903         m = re.search(class_re, tmpsubject, re.IGNORECASE)
904         if m:
905             matches.update(m.groupdict())
906             # Skip to the end of the class identifier, including any
907             # garbage before it.
909             tmpsubject = tmpsubject[m.end():]
911         # if we've not found a valid classname prefix then force the
912         # scanning to handle there being a leading delimiter
913         title_re = r'(?P<title>%s[^%s]+)'%(
914             not matches['classname'] and '.' or '', delim_open)
915         m = re.match(title_re, tmpsubject.strip(), re.IGNORECASE)
916         if m:
917             matches.update(m.groupdict())
918             tmpsubject = tmpsubject[len(matches['title']):] # Consume title
920         args_re = r'(?P<argswhole>%s(?P<args>.+?)%s)?'%(delim_open,
921             delim_close)
922         m = re.search(args_re, tmpsubject.strip(), re.IGNORECASE|re.VERBOSE)
923         if m:
924             matches.update(m.groupdict())
926         # figure subject line parsing modes
927         pfxmode = config['MAILGW_SUBJECT_PREFIX_PARSING']
928         sfxmode = config['MAILGW_SUBJECT_SUFFIX_PARSING']
930         # check for registration OTK
931         # or fallback on the default class
932         if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
933             otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
934             otk = otk_re.search(matches['title'] or '')
935             if otk:
936                 self.db.confirm_registration(otk.group('otk'))
937                 subject = 'Your registration to %s is complete' % \
938                           config['TRACKER_NAME']
939                 sendto = [from_list[0][1]]
940                 self.mailer.standard_message(sendto, subject, '')
941                 return
943         # get the classname
944         if pfxmode == 'none':
945             classname = None
946         else:
947             classname = matches['classname']
949         if not classname and has_prefix and pfxmode == 'strict':
950             raise MailUsageError, _("""
951 The message you sent to roundup did not contain a properly formed subject
952 line. The subject must contain a class name or designator to indicate the
953 'topic' of the message. For example:
954     Subject: [issue] This is a new issue
955       - this will create a new issue in the tracker with the title 'This is
956         a new issue'.
957     Subject: [issue1234] This is a followup to issue 1234
958       - this will append the message's contents to the existing issue 1234
959         in the tracker.
961 Subject was: '%(subject)s'
962 """) % locals()
964         # try to get the class specified - if "loose" or "none" then fall
965         # back on the default
966         attempts = []
967         if classname:
968             attempts.append(classname)
970         if self.default_class:
971             attempts.append(self.default_class)
972         else:
973             attempts.append(config['MAILGW_DEFAULT_CLASS'])
975         # first valid class name wins
976         cl = None
977         for trycl in attempts:
978             try:
979                 cl = self.db.getclass(trycl)
980                 classname = trycl
981                 break
982             except KeyError:
983                 pass
985         if not cl:
986             validname = ', '.join(self.db.getclasses())
987             if classname:
988                 raise MailUsageError, _("""
989 The class name you identified in the subject line ("%(classname)s") does
990 not exist in the database.
992 Valid class names are: %(validname)s
993 Subject was: "%(subject)s"
994 """) % locals()
995             else:
996                 raise MailUsageError, _("""
997 You did not identify a class name in the subject line and there is no
998 default set for this tracker. The subject must contain a class name or
999 designator to indicate the 'topic' of the message. For example:
1000     Subject: [issue] This is a new issue
1001       - this will create a new issue in the tracker with the title 'This is
1002         a new issue'.
1003     Subject: [issue1234] This is a followup to issue 1234
1004       - this will append the message's contents to the existing issue 1234
1005         in the tracker.
1007 Subject was: '%(subject)s'
1008 """) % locals()
1010         # get the optional nodeid
1011         if pfxmode == 'none':
1012             nodeid = None
1013         else:
1014             nodeid = matches['nodeid']
1016         # try in-reply-to to match the message if there's no nodeid
1017         inreplyto = message.getheader('in-reply-to') or ''
1018         if nodeid is None and inreplyto:
1019             l = self.db.getclass('msg').stringFind(messageid=inreplyto)
1020             if l:
1021                 nodeid = cl.filter(None, {'messages':l})[0]
1023         # title is optional too
1024         title = matches['title']
1025         if title:
1026             title = title.strip()
1027         else:
1028             title = ''
1030         # strip off the quotes that dumb emailers put around the subject, like
1031         #      Re: "[issue1] bla blah"
1032         if matches['quote'] and title.endswith('"'):
1033             title = title[:-1]
1035         # but we do need either a title or a nodeid...
1036         if nodeid is None and not title:
1037             raise MailUsageError, _("""
1038 I cannot match your message to a node in the database - you need to either
1039 supply a full designator (with number, eg "[issue123]") or keep the
1040 previous subject title intact so I can match that.
1042 Subject was: "%(subject)s"
1043 """) % locals()
1045         # If there's no nodeid, check to see if this is a followup and
1046         # maybe someone's responded to the initial mail that created an
1047         # entry. Try to find the matching nodes with the same title, and
1048         # use the _last_ one matched (since that'll _usually_ be the most
1049         # recent...). The subject_content_match config may specify an
1050         # additional restriction based on the matched node's creation or
1051         # activity.
1052         tmatch_mode = config['MAILGW_SUBJECT_CONTENT_MATCH']
1053         if tmatch_mode != 'never' and nodeid is None and matches['refwd']:
1054             l = cl.stringFind(title=title)
1055             limit = None
1056             if (tmatch_mode.startswith('creation') or
1057                     tmatch_mode.startswith('activity')):
1058                 limit, interval = tmatch_mode.split(' ', 1)
1059                 threshold = date.Date('.') - date.Interval(interval)
1060             for id in l:
1061                 if limit:
1062                     if threshold < cl.get(id, limit):
1063                         nodeid = id
1064                 else:
1065                     nodeid = id
1067         # if a nodeid was specified, make sure it's valid
1068         if nodeid is not None and not cl.hasnode(nodeid):
1069             if pfxmode == 'strict':
1070                 raise MailUsageError, _("""
1071 The node specified by the designator in the subject of your message
1072 ("%(nodeid)s") does not exist.
1074 Subject was: "%(subject)s"
1075 """) % locals()
1076             else:
1077                 title = subject
1078                 nodeid = None
1080         # Handle the arguments specified by the email gateway command line.
1081         # We do this by looping over the list of self.arguments looking for
1082         # a -C to tell us what class then the -S setting string.
1083         msg_props = {}
1084         user_props = {}
1085         file_props = {}
1086         issue_props = {}
1087         # so, if we have any arguments, use them
1088         if self.arguments:
1089             current_class = 'msg'
1090             for option, propstring in self.arguments:
1091                 if option in ( '-C', '--class'):
1092                     current_class = propstring.strip()
1093                     # XXX this is not flexible enough.
1094                     #   we should chect for subclasses of these classes,
1095                     #   not for the class name...
1096                     if current_class not in ('msg', 'file', 'user', 'issue'):
1097                         mailadmin = config['ADMIN_EMAIL']
1098                         raise MailUsageError, _("""
1099 The mail gateway is not properly set up. Please contact
1100 %(mailadmin)s and have them fix the incorrect class specified as:
1101   %(current_class)s
1102 """) % locals()
1103                 if option in ('-S', '--set'):
1104                     if current_class == 'issue' :
1105                         errors, issue_props = setPropArrayFromString(self,
1106                             cl, propstring.strip(), nodeid)
1107                     elif current_class == 'file' :
1108                         temp_cl = self.db.getclass('file')
1109                         errors, file_props = setPropArrayFromString(self,
1110                             temp_cl, propstring.strip())
1111                     elif current_class == 'msg' :
1112                         temp_cl = self.db.getclass('msg')
1113                         errors, msg_props = setPropArrayFromString(self,
1114                             temp_cl, propstring.strip())
1115                     elif current_class == 'user' :
1116                         temp_cl = self.db.getclass('user')
1117                         errors, user_props = setPropArrayFromString(self,
1118                             temp_cl, propstring.strip())
1119                     if errors:
1120                         mailadmin = config['ADMIN_EMAIL']
1121                         raise MailUsageError, _("""
1122 The mail gateway is not properly set up. Please contact
1123 %(mailadmin)s and have them fix the incorrect properties:
1124   %(errors)s
1125 """) % locals()
1127         #
1128         # handle the users
1129         #
1130         # Don't create users if anonymous isn't allowed to register
1131         create = 1
1132         anonid = self.db.user.lookup('anonymous')
1133         if not (self.db.security.hasPermission('Create', anonid, 'user')
1134                 and self.db.security.hasPermission('Email Access', anonid)):
1135             create = 0
1137         # ok, now figure out who the author is - create a new user if the
1138         # "create" flag is true
1139         author = uidFromAddress(self.db, from_list[0], create=create)
1141         # if we're not recognised, and we don't get added as a user, then we
1142         # must be anonymous
1143         if not author:
1144             author = anonid
1146         # make sure the author has permission to use the email interface
1147         if not self.db.security.hasPermission('Email Access', author):
1148             if author == anonid:
1149                 # we're anonymous and we need to be a registered user
1150                 from_address = from_list[0][1]
1151                 registration_info = ""
1152                 if self.db.security.hasPermission('Web Access', author) and \
1153                    self.db.security.hasPermission('Create', anonid, 'user'):
1154                     tracker_web = self.instance.config.TRACKER_WEB
1155                     registration_info = """ Please register at:
1157 %(tracker_web)suser?template=register
1159 ...before sending mail to the tracker.""" % locals()
1161                 raise Unauthorized, _("""
1162 You are not a registered user.%(registration_info)s
1164 Unknown address: %(from_address)s
1165 """) % locals()
1166             else:
1167                 # we're registered and we're _still_ not allowed access
1168                 raise Unauthorized, _(
1169                     'You are not permitted to access this tracker.')
1171         # make sure they're allowed to edit or create this class of information
1172         if nodeid:
1173             if not self.db.security.hasPermission('Edit', author, classname,
1174                     itemid=nodeid):
1175                 raise Unauthorized, _(
1176                     'You are not permitted to edit %(classname)s.') % locals()
1177         else:
1178             if not self.db.security.hasPermission('Create', author, classname):
1179                 raise Unauthorized, _(
1180                     'You are not permitted to create %(classname)s.'
1181                     ) % locals()
1183         # the author may have been created - make sure the change is
1184         # committed before we reopen the database
1185         self.db.commit()
1187         # set the database user as the author
1188         username = self.db.user.get(author, 'username')
1189         self.db.setCurrentUser(username)
1191         # re-get the class with the new database connection
1192         cl = self.db.getclass(classname)
1194         # now update the recipients list
1195         recipients = []
1196         tracker_email = config['TRACKER_EMAIL'].lower()
1197         for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
1198             r = recipient[1].strip().lower()
1199             if r == tracker_email or not r:
1200                 continue
1202             # look up the recipient - create if necessary (and we're
1203             # allowed to)
1204             recipient = uidFromAddress(self.db, recipient, create, **user_props)
1206             # if all's well, add the recipient to the list
1207             if recipient:
1208                 recipients.append(recipient)
1210         #
1211         # handle the subject argument list
1212         #
1213         # figure what the properties of this Class are
1214         properties = cl.getprops()
1215         props = {}
1216         args = matches['args']
1217         argswhole = matches['argswhole']
1218         if args:
1219             if sfxmode == 'none':
1220                 title += ' ' + argswhole
1221             else:
1222                 errors, props = setPropArrayFromString(self, cl, args, nodeid)
1223                 # handle any errors parsing the argument list
1224                 if errors:
1225                     if sfxmode == 'strict':
1226                         errors = '\n- '.join(map(str, errors))
1227                         raise MailUsageError, _("""
1228 There were problems handling your subject line argument list:
1229 - %(errors)s
1231 Subject was: "%(subject)s"
1232 """) % locals()
1233                     else:
1234                         title += ' ' + argswhole
1237         # set the issue title to the subject
1238         title = title.strip()
1239         if (title and properties.has_key('title') and not
1240                 issue_props.has_key('title')):
1241             issue_props['title'] = title
1243         #
1244         # handle message-id and in-reply-to
1245         #
1246         messageid = message.getheader('message-id')
1247         # generate a messageid if there isn't one
1248         if not messageid:
1249             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
1250                 classname, nodeid, config['MAIL_DOMAIN'])
1252         # if they've enabled PGP processing then verify the signature
1253         # or decrypt the message
1255         # if PGP_ROLES is specified the user must have a Role in the list
1256         # or we will skip PGP processing
1257         def pgp_role():
1258             if self.instance.config.PGP_ROLES:
1259                 return user_has_role(self.db, author,
1260                     self.instance.config.PGP_ROLES)
1261             else:
1262                 return True
1264         if self.instance.config.PGP_ENABLE and pgp_role():
1265             assert pyme, 'pyme is not installed'
1266             # signed/encrypted mail must come from the primary address
1267             author_address = self.db.user.get(author, 'address')
1268             if self.instance.config.PGP_HOMEDIR:
1269                 os.environ['GNUPGHOME'] = self.instance.config.PGP_HOMEDIR
1270             if message.pgp_signed():
1271                 message.verify_signature(author_address)
1272             elif message.pgp_encrypted():
1273                 # replace message with the contents of the decrypted
1274                 # message for content extraction
1275                 # TODO: encrypted message handling is far from perfect
1276                 # bounces probably include the decrypted message, for
1277                 # instance :(
1278                 message = message.decrypt(author_address)
1279             else:
1280                 raise MailUsageError, _("""
1281 This tracker has been configured to require all email be PGP signed or
1282 encrypted.""")
1283         # now handle the body - find the message
1284         ig = self.instance.config.MAILGW_IGNORE_ALTERNATIVES
1285         content, attachments = message.extract_content(ignore_alternatives = ig)
1286         if content is None:
1287             raise MailUsageError, _("""
1288 Roundup requires the submission to be plain text. The message parser could
1289 not find a text/plain part to use.
1290 """)
1292         # parse the body of the message, stripping out bits as appropriate
1293         summary, content = parseContent(content, config=config)
1294         content = content.strip()
1296         #
1297         # handle the attachments
1298         #
1299         if properties.has_key('files'):
1300             files = []
1301             for (name, mime_type, data) in attachments:
1302                 if not self.db.security.hasPermission('Create', author, 'file'):
1303                     raise Unauthorized, _(
1304                         'You are not permitted to create files.')
1305                 if not name:
1306                     name = "unnamed"
1307                 try:
1308                     fileid = self.db.file.create(type=mime_type, name=name,
1309                          content=data, **file_props)
1310                 except exceptions.Reject:
1311                     pass
1312                 else:
1313                     files.append(fileid)
1314             # attach the files to the issue
1315             if not self.db.security.hasPermission('Edit', author,
1316                     classname, 'files'):
1317                 raise Unauthorized, _(
1318                     'You are not permitted to add files to %(classname)s.'
1319                     ) % locals()
1321             if nodeid:
1322                 # extend the existing files list
1323                 fileprop = cl.get(nodeid, 'files')
1324                 fileprop.extend(files)
1325                 props['files'] = fileprop
1326             else:
1327                 # pre-load the files list
1328                 props['files'] = files
1330         #
1331         # create the message if there's a message body (content)
1332         #
1333         if (content and properties.has_key('messages')):
1334             if not self.db.security.hasPermission('Create', author, 'msg'):
1335                 raise Unauthorized, _(
1336                     'You are not permitted to create messages.')
1338             try:
1339                 message_id = self.db.msg.create(author=author,
1340                     recipients=recipients, date=date.Date('.'),
1341                     summary=summary, content=content, files=files,
1342                     messageid=messageid, inreplyto=inreplyto, **msg_props)
1343             except exceptions.Reject, error:
1344                 raise MailUsageError, _("""
1345 Mail message was rejected by a detector.
1346 %(error)s
1347 """) % locals()
1348             # attach the message to the node
1349             if not self.db.security.hasPermission('Edit', author,
1350                     classname, 'messages'):
1351                 raise Unauthorized, _(
1352                     'You are not permitted to add messages to %(classname)s.'
1353                     ) % locals()
1355             if nodeid:
1356                 # add the message to the node's list
1357                 messages = cl.get(nodeid, 'messages')
1358                 messages.append(message_id)
1359                 props['messages'] = messages
1360             else:
1361                 # pre-load the messages list
1362                 props['messages'] = [message_id]
1364         #
1365         # perform the node change / create
1366         #
1367         try:
1368             # merge the command line props defined in issue_props into
1369             # the props dictionary because function(**props, **issue_props)
1370             # is a syntax error.
1371             for prop in issue_props.keys() :
1372                 if not props.has_key(prop) :
1373                     props[prop] = issue_props[prop]
1375             # Check permissions for each property
1376             for prop in props.keys():
1377                 if not self.db.security.hasPermission('Edit', author,
1378                         classname, prop):
1379                     raise Unauthorized, _('You are not permitted to edit '
1380                         'property %(prop)s of class %(classname)s.') % locals()
1382             if nodeid:
1383                 cl.set(nodeid, **props)
1384             else:
1385                 nodeid = cl.create(**props)
1386         except (TypeError, IndexError, ValueError, exceptions.Reject), message:
1387             raise MailUsageError, _("""
1388 There was a problem with the message you sent:
1389    %(message)s
1390 """) % locals()
1392         # commit the changes to the DB
1393         self.db.commit()
1395         return nodeid
1398 def setPropArrayFromString(self, cl, propString, nodeid=None):
1399     ''' takes string of form prop=value,value;prop2=value
1400         and returns (error, prop[..])
1401     '''
1402     props = {}
1403     errors = []
1404     for prop in string.split(propString, ';'):
1405         # extract the property name and value
1406         try:
1407             propname, value = prop.split('=')
1408         except ValueError, message:
1409             errors.append(_('not of form [arg=value,value,...;'
1410                 'arg=value,value,...]'))
1411             return (errors, props)
1412         # convert the value to a hyperdb-usable value
1413         propname = propname.strip()
1414         try:
1415             props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
1416                 propname, value)
1417         except hyperdb.HyperdbValueError, message:
1418             errors.append(str(message))
1419     return errors, props
1422 def extractUserFromList(userClass, users):
1423     '''Given a list of users, try to extract the first non-anonymous user
1424        and return that user, otherwise return None
1425     '''
1426     if len(users) > 1:
1427         for user in users:
1428             # make sure we don't match the anonymous or admin user
1429             if userClass.get(user, 'username') in ('admin', 'anonymous'):
1430                 continue
1431             # first valid match will do
1432             return user
1433         # well, I guess we have no choice
1434         return user[0]
1435     elif users:
1436         return users[0]
1437     return None
1440 def uidFromAddress(db, address, create=1, **user_props):
1441     ''' address is from the rfc822 module, and therefore is (name, addr)
1443         user is created if they don't exist in the db already
1444         user_props may supply additional user information
1445     '''
1446     (realname, address) = address
1448     # try a straight match of the address
1449     user = extractUserFromList(db.user, db.user.stringFind(address=address))
1450     if user is not None:
1451         return user
1453     # try the user alternate addresses if possible
1454     props = db.user.getprops()
1455     if props.has_key('alternate_addresses'):
1456         users = db.user.filter(None, {'alternate_addresses': address})
1457         user = extractUserFromList(db.user, users)
1458         if user is not None:
1459             return user
1461     # try to match the username to the address (for local
1462     # submissions where the address is empty)
1463     user = extractUserFromList(db.user, db.user.stringFind(username=address))
1465     # couldn't match address or username, so create a new user
1466     if create:
1467         # generate a username
1468         if '@' in address:
1469             username = address.split('@')[0]
1470         else:
1471             username = address
1472         trying = username
1473         n = 0
1474         while 1:
1475             try:
1476                 # does this username exist already?
1477                 db.user.lookup(trying)
1478             except KeyError:
1479                 break
1480             n += 1
1481             trying = username + str(n)
1483         # create!
1484         try:
1485             return db.user.create(username=trying, address=address,
1486                 realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
1487                 password=password.Password(password.generatePassword()),
1488                 **user_props)
1489         except exceptions.Reject:
1490             return 0
1491     else:
1492         return 0
1494 def parseContent(content, keep_citations=None, keep_body=None, config=None):
1495     """Parse mail message; return message summary and stripped content
1497     The message body is divided into sections by blank lines.
1498     Sections where the second and all subsequent lines begin with a ">"
1499     or "|" character are considered "quoting sections". The first line of
1500     the first non-quoting section becomes the summary of the message.
1502     Arguments:
1504         keep_citations: declared for backward compatibility.
1505             If omitted or None, use config["MAILGW_KEEP_QUOTED_TEXT"]
1507         keep_body: declared for backward compatibility.
1508             If omitted or None, use config["MAILGW_LEAVE_BODY_UNCHANGED"]
1510         config: tracker configuration object.
1511             If omitted or None, use default configuration.
1513     """
1514     if config is None:
1515         config = configuration.CoreConfig()
1516     if keep_citations is None:
1517         keep_citations = config["MAILGW_KEEP_QUOTED_TEXT"]
1518     if keep_body is None:
1519         keep_body = config["MAILGW_LEAVE_BODY_UNCHANGED"]
1520     eol = config["MAILGW_EOL_RE"]
1521     signature = config["MAILGW_SIGN_RE"]
1522     original_msg = config["MAILGW_ORIGMSG_RE"]
1524     # strip off leading carriage-returns / newlines
1525     i = 0
1526     for i in range(len(content)):
1527         if content[i] not in '\r\n':
1528             break
1529     if i > 0:
1530         sections = config["MAILGW_BLANKLINE_RE"].split(content[i:])
1531     else:
1532         sections = config["MAILGW_BLANKLINE_RE"].split(content)
1534     # extract out the summary from the message
1535     summary = ''
1536     l = []
1537     for section in sections:
1538         #section = section.strip()
1539         if not section:
1540             continue
1541         lines = eol.split(section)
1542         if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
1543                 lines[1] and lines[1][0] in '>|'):
1544             # see if there's a response somewhere inside this section (ie.
1545             # no blank line between quoted message and response)
1546             for line in lines[1:]:
1547                 if line and line[0] not in '>|':
1548                     break
1549             else:
1550                 # we keep quoted bits if specified in the config
1551                 if keep_citations:
1552                     l.append(section)
1553                 continue
1554             # keep this section - it has reponse stuff in it
1555             lines = lines[lines.index(line):]
1556             section = '\n'.join(lines)
1557             # and while we're at it, use the first non-quoted bit as
1558             # our summary
1559             summary = section
1561         if not summary:
1562             # if we don't have our summary yet use the first line of this
1563             # section
1564             summary = section
1565         elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
1566             # lose any signature
1567             break
1568         elif original_msg.match(lines[0]):
1569             # ditch the stupid Outlook quoting of the entire original message
1570             break
1572         # and add the section to the output
1573         l.append(section)
1575     # figure the summary - find the first sentence-ending punctuation or the
1576     # first whole line, whichever is longest
1577     sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
1578     if sentence:
1579         sentence = sentence.group(1)
1580     else:
1581         sentence = ''
1582     first = eol.split(summary)[0]
1583     summary = max(sentence, first)
1585     # Now reconstitute the message content minus the bits we don't care
1586     # about.
1587     if not keep_body:
1588         content = '\n\n'.join(l)
1590     return summary, content
1592 # vim: set filetype=python sts=4 sw=4 et si :