source: trunk/email2trac.py.in @ 664

Last change on this file since 664 was 664, checked in by bas, 9 years ago

Did not apply the patch correctly ;-(, close #364

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 93.8 KB
Line 
1#!@PYTHON@
2#
3# This file is part of the email2trac utils
4#
5#       Copyright 2002 SURFsara
6#
7#       Licensed under the Apache License, Version 2.0 (the "License");
8#       you may not use this file except in compliance with the License.
9#       You may obtain a copy of the License at
10#
11#       http://www.apache.org/licenses/LICENSE-2.0
12#
13#       Unless required by applicable law or agreed to in writing, software
14#       distributed under the License is distributed on an "AS IS" BASIS,
15#       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16#       See the License for the specific language governing permissions and
17#       limitations under the License.
18#
19# For vi/emacs or other use tabstop=4 (vi: set ts=4)
20#
21"""
22email2trac.py -- Email -> TRAC tickets
23
24A MTA filter to create Trac tickets from inbound emails.
25
26first proof of concept from:
27 Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28 Copyright 2005, Edgewall Software
29
30Authors:
31  Bas van der Vlies <bas.vandervlies@surfsara.nl>
32  Walter de Jong <walter.dejong@surfsara.nl>
33
34How to use
35----------
36 * See https://oss.trac.surfsara.nl/email2trac/
37
38 * Commandline opions:
39                -h,--help
40                -A, --agilo
41                -B, --bh_product <name>
42                -d, --debug
43                -E, --virtualenv <path>
44                -f,--file  <configuration file>
45                -n,--dry-run
46                -p, --project <project name>
47                -t, --ticket_prefix <name>
48                -v, --verbose
49
50SVN Info:
51        $Id: email2trac.py.in 664 2015-04-28 06:58:37Z bas $
52"""
53import os
54import sys
55import string
56import getopt
57import time
58import email
59import email.Iterators
60import email.Header
61import re
62import urllib
63import unicodedata
64import mimetypes
65import traceback
66import logging
67import logging.handlers
68import UserDict
69import tempfile
70
71from datetime import timedelta, datetime
72from stat import *
73
74## Some global variables
75#
76m = None
77
78class SaraDict(UserDict.UserDict):
79    def __init__(self, dictin = None):
80        UserDict.UserDict.__init__(self)
81        self.name = None
82       
83        if dictin:
84            if dictin.has_key('name'):
85                self.name = dictin['name']
86                del dictin['name']
87            self.data = dictin
88           
89    def get_value(self, name):
90        if self.has_key(name):
91            return self[name]
92        else:
93            return None
94               
95    def __repr__(self):
96        return repr(self.data)
97
98    def __str__(self):
99        return str(self.data)
100           
101    def __getattr__(self, name):
102        """
103        override the class attribute get method. Return the value
104        from the dictionary
105        """
106        if self.data.has_key(name):
107            return self.data[name]
108        else:
109            return None
110           
111    def __setattr__(self, name, value):
112        """
113        override the class attribute set method only when the UserDict
114        has set its class attribute
115        """
116        if self.__dict__.has_key('data'):
117            self.data[name] = value
118        else:
119            self.__dict__[name] = value
120
121    def __iter__(self):
122        return iter(self.data.keys())
123
124
125
126class TicketEmailParser(object):
127    env = None
128    comment = '> '
129
130    def __init__(self, env, parameters, logger, version):
131        self.env = env
132
133        # Database connection
134        #
135        self.db = None
136
137        # Save parameters
138        #
139        self.parameters = parameters
140        self.logger = logger
141
142        # Some useful mail constants
143        #
144        self.email_name = None
145        self.email_addr = None
146        self.email_from = None
147        self.author     = None
148        self.id         = None
149
150        ## Will be set to True if the user has ben registered
151        #
152        self.allow_registered_user = False
153       
154        self.STRIP_CONTENT_TYPES = list()
155
156        ## fields properties via body_text
157        #
158        self.properties = dict()
159
160        self.VERSION = version
161
162        self.get_config = self.env.config.get
163
164        ## init function ##
165        #
166        self.setup_parameters()
167
168    def setup_parameters(self):
169        if self.parameters.umask:
170            os.umask(self.parameters.umask)
171
172        if not self.parameters.spam_level:
173            self.parameters.spam_level = 0
174
175        if not self.parameters.spam_header:
176            self.parameters.spam_header = 'X-Spam-Score'
177
178        if not self.parameters.email_quote:
179            self.parameters.email_quote = '^> .*'
180            self.parameters.skip_line_regex = '^> .*'
181
182        if not self.parameters.ticket_update_by_subject_lookback:
183            self.parameters.ticket_update_by_subject_lookback = 30
184
185        if self.parameters.verbatim_format == None:
186            self.parameters.verbatim_format = 1
187
188        if self.parameters.reflow == None:
189            self.parameters.reflow = 1
190
191        if self.parameters.binhex:
192            self.STRIP_CONTENT_TYPES.append('application/mac-binhex40')
193
194        if self.parameters.applesingle:
195            self.STRIP_CONTENT_TYPES.append('application/applefile')
196
197        if self.parameters.appledouble:
198            self.STRIP_CONTENT_TYPES.append('application/applefile')
199
200        if self.parameters.strip_content_types:
201            items = self.parameters.strip_content_types.split(',')
202            for item in items:
203                self.STRIP_CONTENT_TYPES.append(item.strip())
204
205        if self.parameters.tmpdir:
206            self.parameters.tmpdir = os.path.normcase(str(self.parameters['tmpdir']))
207        else:
208            self.parameters.tmpdir = os.path.normcase('/tmp')
209
210        if self.parameters.email_triggers_workflow == None:
211            self.parameters.email_triggers_workflow = 1
212
213        if not self.parameters.subject_field_separator:
214            self.parameters.subject_field_separator = '&'
215        else:
216            self.parameters.subject_field_separator = self.parameters.subject_field_separator.strip()
217
218        if not self.parameters.strip_signature_regex:
219            self.parameters.strip_signature_regex = '^-- $'
220            self.parameters.cut_line_regex = '^-- $'
221
222        self.trac_smtp_from = self.get_config('notification', 'smtp_from')
223        self.smtp_default_domain = self.get_config('notification', 'smtp_default_domain')
224        self.smtp_replyto = self.get_config('notification', 'smtp_replyto')
225        self.trac_smtp_always_cc = self.get_config('notification', 'smtp_always_cc')
226        self.trac_smtp_always_bcc = self.get_config('notification', 'smtp_always_bcc')
227
228        self.system = None
229
230########## Email Header Functions ###########################################################
231
232    def spam(self, message):
233        """
234        # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
235        # Note if Spam_level then '*' are included
236        """
237        self.logger.debug('function spam')
238
239        spam = False
240        if message.has_key(self.parameters.spam_header):
241            spam_l = string.split(message[self.parameters.spam_header])
242            #self.logger.info('Spam header: %s' %(message[self.parameters.spam_header]))
243
244            try:
245                number = spam_l[0].count('*')
246            except IndexError, detail:
247                number = 0
248               
249            if number >= self.parameters.spam_level:
250                spam = True
251               
252        ## treat virus mails as spam
253        #
254        elif message.has_key('X-Virus-found'):         
255            spam = True
256
257        ## Astaro firewall spam handling
258        #
259        elif message.get('X-Spam-Flag') == "YES" and message.get('X-Spam-Result') == "Spam":
260            spam = True
261
262        ## How to handle SPAM messages
263        #
264        if self.parameters.drop_spam and spam:
265
266            self.logger.info('Message is a SPAM. Automatic ticket insertion refused (SPAM level > %d)' %self.parameters.spam_level)
267            return 'drop'   
268
269        elif spam:
270
271            return 'Spam'   
272        else:
273
274            return False
275
276    def email_header_acl(self, keyword, message_field, default):
277        """
278        This function wil check if the email address is allowed or denied
279        to send mail to the ticket list
280        """
281        self.logger.debug('function email_header_acl: %s' %keyword)
282
283        try:
284            mail_addresses = self.parameters[keyword]
285
286            # Check if we have an empty string
287            #
288            if not mail_addresses:
289                return default
290
291        except KeyError, detail:
292            self.logger.debug('\t %s not defined, all messages are allowed.' %(keyword))
293
294            return default
295
296        mail_addresses = string.split(mail_addresses, ',')
297        message_addresses = string.split(message_field, ',')
298
299        for entry in mail_addresses:
300            entry = entry.strip()
301            TO_RE = re.compile(entry, re.VERBOSE|re.IGNORECASE)
302
303            for addr in message_addresses:
304                addr = addr.strip()
305
306                if self.parameters.compare_function_list in [ 'matches', 'match']:
307                    s = '\t%s matches %s' %(addr, entry)
308                    result =  TO_RE.match(addr)
309                else:
310                    s = '\t%s contains %s' %(addr, entry)
311                    result =  TO_RE.search(addr)
312
313                if result:
314                    self.logger.debug(s)
315                    return True
316
317        return False
318
319    def email_header_txt(self, m):
320        """
321        Display To and CC addresses in description field
322        """
323        s = ''
324
325        if m['To'] and len(m['To']) > 0:
326            s = "'''To:''' %s\r\n" %(m['To'])
327        if m['Cc'] and len(m['Cc']) > 0:
328            s = "%s'''Cc:''' %s\r\n" % (s, m['Cc'])
329
330        return  self.email_to_unicode(s)
331
332
333    def get_sender_info(self, message):
334        """
335        Get the default author name and email address from the message
336        """
337        self.logger.debug('function get_sender_info')
338
339        to_addrs = email.Utils.getaddresses( message.get_all('to', []) )
340        self.email_to_addrs = list()
341
342        for n,e in to_addrs:
343            self.email_to_addrs.append(e)
344
345        self.email_to_addrs = ','.join(self.email_to_addrs)
346
347
348        self.email_name, self.email_addr  = email.Utils.parseaddr(message['from'])
349
350        ## decode email name can contain charset
351        #
352        self.email_name = self.email_to_unicode(self.email_name)
353
354        dstr = '\t email name: %s, email address: %s' %(self.email_name, self.email_addr) 
355        self.logger.debug(dstr)
356
357        ## Trac can not handle author's name that contains spaces
358        #
359        if self.email_addr.lower() == self.trac_smtp_from.lower():
360            if self.email_name:
361                self.author = self.email_name
362            else:
363                self.author = "email2trac"
364        else:
365            self.author = self.email_addr
366
367        if self.parameters.ignore_trac_user_settings:
368            return
369
370        # Is this a registered user, use email address as search key:
371        # result:
372        #   u : login name
373        #   n : Name that the user has set in the settings tab
374        #   e : email address that the user has set in the settings tab
375        #
376        users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
377            if (
378                (e and (e.lower() == self.email_addr.lower())) or
379                (u and (u.lower() + '@' + self.smtp_default_domain.lower() == self.email_addr.lower()))
380            )
381            ]
382
383        if len(users) >= 1:
384            self.email_from = users[0][2]
385            self.author = users[0][0]
386
387            self.logger.debug('\tget_sender_info: found registered user: %s' %(self.author) )
388
389            if self.parameters.white_list_registered_users:
390                self.logger.debug('\tget_sender_info: added registered user to white list')
391                self.allow_registered_user = True
392                   
393
394    def set_cc_fields(self, ticket, message, update=False):
395        """
396        Set all the right fields for a new ticket
397        """
398        self.logger.debug('function set_cc_fields')
399
400
401        # Put all CC-addresses in ticket CC field
402        #
403        if self.parameters.reply_all:
404
405            ticket_cc = ''
406
407            msg_cc_addrs = email.Utils.getaddresses( message.get_all('cc', []) )
408
409            if not msg_cc_addrs:
410                return
411
412            if update:
413                self.logger.debug("\tupdate ticket cc-field")
414                ticket_cc = ticket['cc']
415                ticket_cc_list = ticket_cc.split(',')
416
417            for name,addr in msg_cc_addrs:
418       
419                ## Prevent mail loop
420                #
421                if addr == self.trac_smtp_from:
422                    self.logger.debug("\tSkipping %s email address for CC-field, same as smtp_from address in trac.ini " %(addr))
423                    continue
424
425                ## Alwyas remove reporter email address
426                #
427                elif addr == self.email_addr:
428                    self.logger.debug("\tSkipping reporter email address for CC-field")
429                    continue
430
431                ## Always remove the always_cc address
432                #
433                elif addr == self.trac_smtp_always_cc:
434                    self.logger.debug("\tSkipping smtp_always_cc email address for CC-field")
435                    continue
436
437                ## Always remove the always_bcc address
438                #
439                elif addr == self.trac_smtp_always_bcc:
440                    self.logger.debug("\tSkipping smtp_always_bcc email address for CC-field")
441                    continue
442
443                ## Skip also addr in cc_black_list (email2trac.conf)
444                #
445                if self.email_header_acl('cc_black_list', addr, False):
446                    self.logger.debug("\tSkipping address for CC-field due to cc_black_list")
447                    continue
448
449                else:
450                    ## On update, prevent adding duplicates
451                    #
452                    if update:
453                      if addr in ticket_cc_list:
454                          continue
455                   
456                    if ticket_cc:
457                        ticket_cc = '%s, %s' %(ticket_cc, addr)
458                    else:
459                        ticket_cc = addr
460
461            if ticket_cc:
462                self.logger.debug('\tCC fields: %s' %ticket_cc)
463                ticket['cc'] = self.email_to_unicode(ticket_cc)
464
465
466    def acl_list_from_file(self, f, keyword):
467        """
468        Read the email address from a file
469        """
470        self.logger.debug('function acl_list_from_file %s : %s' %(f, keyword))
471       
472        if  not os.path.isfile(f):
473            self.logger.error('%s_file: %s does not exists' %(keyword, f) )
474        else:
475            ## read whole file and replace '\n' with ''
476            #
477            addr_l = open(f, 'r').readlines()
478            s = ','.join(addr_l).replace('\n','')
479
480            try:
481                self.parameters[keyword] = "%s,%s" %(self.parameters[keyword], s)
482            except KeyError, detail:
483                self.parameters[keyword] = s
484
485########## DEBUG functions  ###########################################################
486
487    def debug_body(self, message_body, temp_file):
488        """
489        """
490        self.logger.debug('function debug_body:')
491
492        body_file = "%s.body" %(temp_file)
493        fx = open(body_file, 'wb')
494
495        if self.parameters.dry_run:
496            self.logger.info('DRY-RUN: not saving body to %s' %(body_file))
497            return
498
499        self.logger.debug('writing body to %s' %(body_file))
500        if not message_body:
501                message_body = '(None)'
502
503        message_body = message_body.encode('utf-8')
504
505        fx.write(message_body)
506        fx.close()
507        try:
508            os.chmod(body_file,S_IRWXU|S_IRWXG|S_IRWXO)
509        except OSError:
510            pass
511
512    def debug_attachments(self, message_parts, temp_file):
513        """
514        """
515        self.logger.debug('function debug_attachments')
516       
517        n = 0
518        for item in message_parts:
519
520            ## Skip inline text parts
521            #
522            if not isinstance(item, tuple):
523                continue
524               
525            (original, filename, part) = item
526            self.logger.debug('\t part%d: Content-Type: %s' % (n, part.get_content_type()) )
527
528            dummy_filename, ext = os.path.splitext(filename)
529
530            n = n + 1
531            part_name = 'part%d%s' %(n, ext)
532            part_file = "%s.%s" %(temp_file, part_name)
533
534            s = 'writing %s: filename: %s' %(part_file, filename)
535            self.print_unicode(s)
536
537            ## Forbidden chars, just write part files instead of names
538            #
539            #filename = filename.replace('\\', '_')
540            #filename = filename.replace('/', '_')
541            #filename = filename + '.att_email2trac'
542            # part_file = os.path.join(self.parameters.tmpdir, filename)
543            #part_file = util.text.unicode_quote(part_file)
544            #self.print_unicode(part_file)
545
546            if self.parameters.dry_run:
547                self.logger.info('DRY_RUN: NOT saving attachments')
548                continue
549
550            fx = open(part_file, 'wb')
551            text = part.get_payload(decode=1)
552
553            if not text:
554                text = '(None)'
555
556            fx.write(text)
557            fx.close()
558
559            try:
560                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
561            except OSError:
562                pass
563
564    def save_email_for_debug(self, message, project_name, create_tempfile=False):
565
566        if self.parameters.dry_run:
567            self.logger.debug('DRY_RUN: NOT saving email message')
568            return
569
570        if create_tempfile:
571            (fd, tmp_file) = tempfile.mkstemp('.%s.email2trac' %project_name)
572            fx = os.fdopen(fd, 'wb')
573        else:
574            tmp_file = os.path.join(self.parameters.tmpdir, 'msg.%s.email2trac' %project_name)
575            fx = open(tmp_file, 'wb')
576
577
578        self.logger.debug('saving email to %s' %(tmp_file))
579        fx.write('%s' % message)
580        fx.close()
581       
582        try:
583            os.chmod(tmp_file, S_IRWXU|S_IRWXG|S_IRWXO)
584        except OSError:
585            pass
586
587        message_parts = self.get_message_parts(message)
588        message_parts = self.unique_attachment_names(message_parts)
589        body_text = self.get_body_text(message_parts)
590        self.debug_body(body_text, tmp_file)
591        self.debug_attachments(message_parts, tmp_file)
592
593########## Conversion functions  ###########################################################
594
595    def email_to_unicode(self, message_str):
596        """
597        Email has 7 bit ASCII code, convert it to unicode with the charset
598        that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
599        understands it.
600        """
601        self.logger.debug("function email_to_unicode")
602
603        self.logger.debug("\t repr:%s type:%s" %(repr(message_str), type(message_str)))
604
605        ## Skip unicode strings, there are already converted
606        #
607        if type(message_str) is unicode:
608            return message_str
609
610        results =  email.Header.decode_header(message_str)
611
612        s = None
613        for text,format in results:
614            if format:
615                try:
616                    temp = unicode(text, format)
617                except UnicodeError, detail:
618                    # This always works
619                    #
620                    msg = 'ERROR: Could not find charset: %s, please install' %format
621                    self.logger.error(msg)
622                    temp = unicode(text, 'iso-8859-15')
623                except LookupError, detail:
624                    msg = 'ERROR: Could not find charset: %s, please install' %format
625                    self.logger.error(msg)
626                    #temp = unicode(text, 'iso-8859-15')
627                    temp = message_str
628                       
629            else:
630                temp = string.strip(text)
631                temp = unicode(text, 'iso-8859-15')
632
633            if s:
634                s = '%s %s' %(s, temp)
635            else:
636                s = '%s' %temp
637
638        return s
639
640    def str_to_dict(self, s):
641        """
642        Transfrom a string of the form [<key>=<value>]+ to dict[<key>] = <value>
643        """
644        self.logger.debug("function str_to_dict")
645
646        fields = string.split(s, self.parameters.subject_field_separator)
647
648        result = dict()
649        for field in fields:
650            try:
651                index, value = string.split(field, '=')
652
653                # We can not change the description of a ticket via the subject
654                # line. The description is the body of the email
655                #
656                if index.lower() in ['description']:
657                    continue
658
659                if value:
660                    result[index.lower()] = value
661
662            except ValueError:
663                pass
664        return result
665
666    def print_unicode(self,s):
667        """
668        This function prints unicode strings if possible else it will quote it
669        """
670        try:
671            self.logger.debug(s)
672        except UnicodeEncodeError, detail:
673            self.logger.debug(util.text.unicode_quote(s))
674
675
676    def html_2_txt(self, data):
677        """
678        Various routines to convert html syntax to valid trac wiki syntax
679        """
680        self.logger.debug('function html_2_txt')
681
682        ## This routine make an safe html that can be include
683        #  in trac, but no further text processing can be done
684        #
685#       try:
686#           from lxml.html.clean import Cleaner
687#           tags_rm = list()
688#           tags_rm.append('body')
689#
690#           cleaner = Cleaner(remove_tags=tags_rm )
691#           parsed_data = cleaner.clean_html(data)
692#           parsed_data = '\n{{{\n#!html\n' + parsed_data + '\n}}}\n'
693#
694#           return parsed_data
695#           
696#       except ImportError::
697#           pass
698
699        parsed_data = None
700        if self.parameters.html2text_cmd:
701            (fd, tmp_file) = tempfile.mkstemp('email2trac.html')
702            f = os.fdopen(fd, 'w')
703
704            cmd = '%s %s' %(self.parameters.html2text_cmd, tmp_file)
705            self.logger.debug('\t html2text conversion %s'%(cmd))
706   
707            if self.parameters.dry_run:
708                self.logger.info('DRY_RUN: html2text conversion command: %s\n' %(cmd))
709
710            else:
711                f.write(data)
712                f.close()
713
714                lines = os.popen(cmd).readlines()
715                parsed_data =  ''.join(lines)
716
717                os.unlink(tmp_file)
718
719        else:
720            self.logger.debug('\t No html2text conversion tool specified in email2trac.conf')
721
722        return parsed_data
723
724    def check_filename_length(self, name):
725        """
726        To bypass a bug in Trac
727        check if the filename length is not larger then OS limit.
728          yes : return truncated filename
729          no  : return unmodified filename
730        """
731        self.logger.debug('function check_filename_length: ')
732
733        if not name:
734            return 'None'
735       
736        dummy_filename, ext = os.path.splitext(name)
737
738        ## Trac uses this format
739        #
740        try:
741            quote_format = util.text.unicode_quote(dummy_filename)
742
743        except UnicodeDecodeError, detail:
744            ## last resort convert to unicode
745            #
746            dummy_filename = util.text.to_unicode(dummy_filename)
747            quote_format = util.text.unicode_quote(dummy_filename)
748
749        ## Determine max filename length
750        #
751        try:
752            filemax_length = os.pathconf('/', 'PC_NAME_MAX')
753        except AttributeError, detail:
754            filemax_length = 240
755
756        if len(quote_format) <= filemax_length:
757            return name
758
759        else:
760            ## Truncate file to filemax_length and reserve room for extension
761            #  We must break on a boundry
762            #
763            length = filemax_length - 6
764
765            for i in range(0,10):
766
767                truncated = quote_format[ : (length - i)]
768
769                try:
770                    unqoute_truncated = util.text.unicode_unquote(truncated)
771                    unqoute_truncated = unqoute_truncated + ext
772
773                    self.print_unicode('\t ' + unqoute_truncated)
774
775                    break
776
777                except UnicodeDecodeError, detail:
778                    continue
779
780
781            return unqoute_truncated
782
783########## TRAC ticket functions  ###########################################################
784
785    def mail_workflow(self, tkt):
786        """
787        """
788        self.logger.debug('function mail_workflow: ')
789       
790        req = Mock(authname=self.author, perm=MockPerm(), args={})
791        ticket_system = TicketSystem(self.env)
792       
793        try:
794            workflow = self.parameters['workflow_%s' %tkt['status'].lower()]
795
796        except KeyError:
797            ## fallback for compability (Will be deprecated)
798            #  workflow can be none.
799            #
800            workflow = None
801            if tkt['status'] in ['closed']:
802                workflow = self.parameters.workflow
803
804        if workflow:
805
806            ## process all workflow implementations
807            #
808            tkt_module = TicketModule(self.env)
809            field_changes, problems = tkt_module.get_ticket_changes(req, tkt, workflow)
810
811            for field in field_changes.keys():
812
813                ## We have already processed these fields
814                #
815                if not field in ['summary', 'description']:
816                    s = 'workflow : %s, field %s : %s, by %s' \
817                       %(workflow, field, field_changes[field]['new'],field_changes[field]['by'] )
818                    self.logger.debug(s)
819
820                    tkt[field] = field_changes[field]['new']
821
822            return True
823
824        else:
825            return False
826
827    def check_permission_participants(self, tkt, action):
828        """
829        Check if the mailer is allowed to update the ticket
830        """
831        self.logger.debug('function check_permission_participants %s')
832
833        if tkt['reporter'].lower() in [self.author.lower(), self.email_addr.lower()]:
834            self.logger.debug('ALLOW, %s is the ticket reporter' %(self.email_addr))
835
836            return True
837
838        perm = PermissionSystem(self.env)
839        if perm.check_permission(action, self.author):
840            self.logger.debug('ALLOW, %s has trac permission to update the ticket' %(self.author))
841
842            return True
843       
844        # Is the updater in the CC?
845        try:
846            cc_list = tkt['cc'].split(',')
847            for cc in cc_list:
848                if self.email_addr.lower() in cc.lower().strip():
849                    self.logger.debug('ALLOW, %s is in the CC' %(self.email_addr))
850
851                    return True
852
853        except KeyError:
854            pass
855
856        return False
857
858    def check_permission(self, tkt, action):
859        """
860        check if the reporter has the right permission for the action:
861          - TICKET_CREATE
862          - TICKET_MODIFY
863          - TICKET_APPEND
864          - TICKET_CHGPROP
865
866        There are three models:
867            - None      : no checking at all
868            - trac      : check the permission via trac permission model
869            - email2trac: ....
870        """
871        self.logger.debug("function check_permission: %s" %(action))
872
873        if self.parameters.ticket_permission_system in ['trac']:
874
875            perm = PermissionCache(self.env, self.author)
876            if perm.has_permission(action):
877                return True
878            else:
879                return False
880
881        elif self.parameters.ticket_permission_system in ['update_restricted_to_participants']:
882            return (self.check_permission_participants(tkt, action))   
883
884        ## Default is to allow everybody ticket updates and ticket creation
885        #
886        else:
887                return True
888
889
890    def update_ticket_fields(self, ticket, user_dict, new=None):
891        """
892        This will update the ticket fields. It will check if the
893        given fields are known and if the right values are specified
894        It will only update the ticket field value:
895            - If the field is known
896            - If the value supplied is valid for the ticket field.
897              If not then there are two options:
898               1) Skip the value (new=None)
899               2) Set default value for field (new=1)
900        """
901        self.logger.debug("function update_ticket_fields")
902
903        if self.parameters.bh_product:
904            if 'product' in user_dict:
905                if user_dict['product'] != ticket.env.product.prefix:
906                    self.logging.warning("bloodhound products cannot be changed "
907                                         "- ignoring")
908                user_dict.pop('product')
909
910        ## Check only permission model on ticket updates
911        #
912        if not new:
913            if self.parameters.ticket_permission_system:
914                if not self.check_permission(ticket, 'TICKET_CHGPROP'):
915                    self.logger.info('Reporter: %s has no permission to change ticket properties' %self.author)
916                    return False
917
918        ## Build a system dictionary from the ticket fields
919        #  with field as index and option as value
920        #
921        sys_dict = dict()
922
923        for field in ticket.fields:
924
925            try:
926                sys_dict[field['name']] = field['options']
927
928            except KeyError:
929                sys_dict[field['name']] = None
930                pass
931
932        ## Check user supplied fields an compare them with the
933        #  system one's
934        #
935        for field,value in user_dict.items():
936       
937            s = 'user_field\t %s = %s' %(field,value)
938            self.print_unicode(s)
939
940            if not field in sys_dict.keys(): 
941                self.logger.debug('%s is not a valid field for tickets' %(field))
942                continue
943
944            ## To prevent mail loop
945            #
946            if field == 'cc':
947
948                cc_list = user_dict['cc'].split(',')
949
950                if self.trac_smtp_from in cc_list:
951                    self.logger.debug('MAIL LOOP: %s is not allowed as CC address' %(self.trac_smtp_from))
952
953                    cc_list.remove(self.trac_smtp_from)
954
955                value = ','.join(cc_list)
956               
957
958            ## Check if every value is allowed for this field
959            #
960            if sys_dict[field]:
961
962                if value in sys_dict[field]:
963                    ticket[field] = value
964                else:
965                    ## Must we set a default if value is not allowed
966                    #
967                    if new:
968                        value = self.get_config('ticket', 'default_%s' %(field) )
969
970            else:
971
972                ticket[field] = value
973
974            s = 'ticket_field\t %s = %s' %(field,  ticket[field])
975            self.print_unicode(s)
976
977    def ticket_update(self, m, id, spam):
978        """
979        If the current email is a reply to an existing ticket, this function
980        will append the contents of this email to that ticket, instead of
981        creating a new one.
982        """
983        self.logger.debug("function ticket_update")
984
985        if not self.parameters.ticket_update:
986            self.logger.debug("ticket_update disabled")
987            return False
988
989        ## Must we update ticket fields
990        #
991        update_fields = dict()
992        try:
993            id, keywords = string.split(id, '?')
994
995            update_fields = self.str_to_dict(keywords)
996
997            ## Strip '#'
998            #
999            self.id = int(id[1:])
1000
1001        except ValueError:
1002
1003            ## Strip '#'
1004            #
1005            self.id = int(id[1:])
1006
1007        self.logger.debug("\tticket id: %s" %id)
1008
1009        ## When is the change committed
1010        #
1011        if self.VERSION < 0.11:
1012            when = int(time.time())
1013        else:
1014            when = datetime.now(util.datefmt.utc)
1015
1016        try:
1017            tkt = Ticket(self.env, self.id, self.db)
1018
1019        except util.TracError, detail:
1020
1021            ## Not a valid ticket
1022            #
1023            self.logger.info("\tCreating a new ticket, ticket id: %s does not exists" %id)
1024            self.id = None
1025            return False
1026
1027        ## Check the permission of the reporter
1028        #
1029        if self.parameters.ticket_permission_system:
1030            if not self.check_permission(tkt, 'TICKET_APPEND'):
1031                self.logger.info('Reporter: %s has no permission to add comments or attachments to tickets' %self.author)
1032                return False
1033
1034        ## How many changes has this ticket
1035        #
1036        grouped = TicketModule(self.env).grouped_changelog_entries(tkt, self.db)
1037        cnum = sum(1 for e in grouped) + 1
1038
1039
1040        ## reopen the ticket if it is was closed
1041        #  We must use the ticket workflow framework
1042        #
1043        if self.parameters.email_triggers_workflow and (self.VERSION >= 0.11):
1044
1045            if not self.mail_workflow(tkt):
1046
1047                if tkt['status'] in ['closed']:
1048                    tkt['status'] = 'reopened'
1049                    tkt['resolution'] = ''
1050
1051        ## Old pre 0.11 situation
1052        #
1053        elif self.parameters.email_triggers_workflow:
1054
1055            self.logger.debug('\temail triggers workflow pre trac 0.11')
1056
1057            if tkt['status'] in ['closed']:
1058                tkt['status'] = 'reopened'
1059                tkt['resolution'] = ''
1060
1061        else:
1062            self.logger.debug('\temail triggers workflow disabled')
1063
1064        ## Must we update some ticket fields properties via subject line
1065        #
1066        if update_fields:
1067            self.update_ticket_fields(tkt, update_fields)
1068
1069
1070        message_parts = self.get_message_parts(m)
1071        message_parts = self.unique_attachment_names(message_parts)
1072
1073        ## Must we update some ticket fields properties via inline comments
1074        # in body_text
1075        #
1076        if self.properties:
1077                self.update_ticket_fields(tkt, self.properties)
1078
1079        ## Must we update the CC ticket field
1080        #
1081        self.set_cc_fields(tkt, m, update=True)
1082
1083        if self.parameters.email_header:
1084            message_parts.insert(0, self.email_header_txt(m))
1085
1086        body_text = self.get_body_text(message_parts)
1087
1088        error_with_attachments = self.attach_attachments(message_parts)
1089
1090        if body_text.strip() or update_fields or self.properties:
1091
1092            if self.parameters.dry_run:
1093                s = 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) %s %s' %(self.author, cnum)
1094                self.logger.info(s)
1095
1096            else:
1097                if error_with_attachments:
1098                    body_text = '%s\\%s' %(error_with_attachments, body_text)
1099
1100                self.logger.debug('\ttkt.save_changes(%s, %d)' %(self.author, cnum))
1101                tkt.save_changes(self.author, body_text, when, None, str(cnum))
1102           
1103        if not spam:
1104            self.notify(tkt, False, when)
1105
1106        return True
1107
1108    def set_ticket_fields(self, ticket):
1109        """
1110        set the ticket fields to value specified
1111            - /etc/email2trac.conf with <prefix>_<field>
1112            - trac default values, trac.ini
1113        """
1114        self.logger.debug('function set_ticket_fields')
1115
1116        user_dict = dict()
1117
1118        for field in ticket.fields:
1119
1120            name = field['name']
1121
1122            ## default trac value
1123            #
1124            CUSTOM_FIELD = False
1125            if not field.get('custom'):
1126                value = self.get_config('ticket', 'default_%s' %(name) )
1127
1128                ## skip this field can only be set by email2trac.conf
1129                #
1130                if name in ['resolution']:
1131                    value = None
1132
1133            else:
1134                ##  Else get the default value for custom fields
1135                #
1136                CUSTOM_FIELD = True
1137                value = field.get('value')
1138                options = field.get('options')
1139
1140                if value and options and (value not in options):
1141                     value = options[int(value)]
1142   
1143            s = 'trac[%s] = %s' %(name, value)
1144            self.print_unicode(s)
1145
1146            ## email2trac.conf settings
1147            #
1148            prefix = self.parameters.ticket_prefix
1149            try:
1150                value = self.parameters['%s_%s' %(prefix, name)]
1151
1152                s = 'email2trac[%s] = %s ' %(name, value)
1153                self.print_unicode(s)
1154
1155            except KeyError, detail:
1156                pass
1157       
1158            if value:
1159                user_dict[name] = value
1160
1161                s = 'used %s = %s' %(name, value)
1162                self.print_unicode(s)
1163
1164            else:
1165                ## custom fields need some initialisation
1166                #
1167                if CUSTOM_FIELD:
1168                    user_dict[name] = ''
1169                   
1170        self.update_ticket_fields(ticket, user_dict, new=1)
1171
1172        if 'status' not in user_dict.keys():
1173            ticket['status'] = 'new'
1174
1175    def ticket_update_by_subject(self, subject):
1176        """
1177        This list of Re: prefixes is probably incomplete. Taken from
1178        wikipedia. Here is how the subject is matched
1179          - Re: <subject>
1180          - Re: (<Mail list label>:)+ <subject>
1181
1182        So we must have the last column
1183        """
1184        self.logger.debug('function ticket_update_by_subject')
1185
1186        found_id = None
1187        if self.parameters.ticket_update and self.parameters.ticket_update_by_subject:
1188               
1189            SUBJECT_RE = re.compile(r'^(?:(?:RE|AW|VS|SV|FW|FWD):\s*)+(.*)', re.IGNORECASE)
1190            result = SUBJECT_RE.search(subject)
1191
1192            if result:
1193                ## This is a reply
1194                #
1195                orig_subject = result.group(1)
1196
1197                self.logger.debug('subject search string: %s' %(orig_subject))
1198
1199                cursor = self.db.cursor()
1200                summaries = [orig_subject, '%%: %s' % orig_subject]
1201
1202                ## Time resolution is in micoseconds
1203                #
1204                search_date = datetime.now(util.datefmt.utc) - timedelta(days=self.parameters.ticket_update_by_subject_lookback)
1205
1206                if self.VERSION < 0.12:
1207                    lookback = util.datefmt.to_timestamp(search_date)
1208                else:
1209                    lookback = util.datefmt.to_utimestamp(search_date)
1210
1211                for summary in summaries:
1212                    self.logger.debug('Looking for summary matching: "%s"' % summary)
1213
1214                    sql = """SELECT id, reporter FROM ticket
1215                            WHERE changetime >= %s AND summary LIKE %s
1216                            ORDER BY changetime DESC"""
1217
1218                    cursor.execute(sql, [lookback, summary.strip()])
1219
1220                    for row in cursor:
1221
1222                        (matched_id, sql_reporter) = row
1223
1224                        ## Save first entry.
1225                        #
1226                        if not found_id:
1227                            found_id = matched_id
1228                           
1229                        ## If subject and reporter are the same. The we certainly have found the right ticket
1230                        #
1231                        if sql_reporter == self.author:
1232                            self.logger.debug('Found matching reporter: %s with ticket id: %d' %(sql_reporter, matched_id))
1233                            found_id = matched_id
1234                            break
1235
1236                    if found_id:
1237                        self.logger.debug('Found matching ticket id: %d' % found_id)
1238                        found_id = '#%d' % found_id
1239                        return (found_id, orig_subject)
1240                   
1241        return (found_id, subject)
1242
1243
1244    def new_ticket(self, msg, subject, spam, set_fields = None):
1245        """
1246        Create a new ticket
1247        """
1248        self.logger.debug('function new_ticket')
1249
1250        tkt = Ticket(self.env)
1251
1252        ## self.author can be email address of an username
1253        #
1254        tkt['reporter'] = self.author
1255
1256        self.set_cc_fields(tkt, msg)
1257
1258        self.set_ticket_fields(tkt)
1259
1260        ## Check the permission of the reporter
1261        #
1262        if self.parameters.ticket_permission_system:
1263            if not self.check_permission(tkt, 'TICKET_CREATE'):
1264                self.logger.info('Reporter: %s has no permission to create tickets' %self.author)
1265                return False
1266
1267        ## Old style setting for component, will be removed
1268        #
1269        if spam:
1270            tkt['component'] = 'Spam'
1271
1272        elif self.parameters.has_key('component'):
1273            tkt['component'] = self.parameters['component']
1274
1275        if not msg['Subject']:
1276            tkt['summary'] = u'(No subject)'
1277        else:
1278            tkt['summary'] = subject
1279
1280
1281        if set_fields:
1282            rest, keywords = string.split(set_fields, '?')
1283
1284            if keywords:
1285                update_fields = self.str_to_dict(keywords)
1286                self.update_ticket_fields(tkt, update_fields)
1287
1288
1289        message_parts = self.get_message_parts(msg, True)
1290
1291        ## Must we update some ticket fields properties via body_text
1292        #
1293        if self.properties:
1294                self.update_ticket_fields(tkt, self.properties)
1295
1296        message_parts = self.unique_attachment_names(message_parts)
1297       
1298        ## produce e-mail like header
1299        #
1300        head = ''
1301        if self.parameters.email_header:
1302            head = self.email_header_txt(msg)
1303            message_parts.insert(0, head)
1304           
1305        body_text = self.get_body_text(message_parts)
1306
1307        tkt['description'] = body_text
1308
1309        ## When is the change committed
1310        #
1311        if self.VERSION < 0.11:
1312            when = int(time.time())
1313        else:
1314            when = datetime.now(util.datefmt.utc)
1315
1316        if self.parameters.dry_run:
1317            self.logger.info('DRY_RUN: tkt.insert()')
1318        else:
1319            self.id = tkt.insert()
1320   
1321        changed = False
1322        comment = ''
1323
1324        ## some routines in trac are dependend on ticket id
1325        #  like alternate notify template
1326        #
1327        if self.parameters.alternate_notify_template:
1328            tkt['id'] = self.id
1329            changed = True
1330
1331        ## Rewrite the description if we have mailto enabled
1332        #
1333        if self.parameters.mailto_link:
1334            changed = True
1335            comment = u'\nadded mailto line\n'
1336
1337            #mailto = self.html_mailto_link( m['Subject'])
1338            mailto = self.html_mailto_link(subject)
1339
1340            tkt['description'] = u'%s\r\n%s%s\r\n' \
1341                %(head, mailto, body_text)
1342   
1343        ## Save the attachments to the ticket   
1344        #
1345        error_with_attachments =  self.attach_attachments(message_parts)
1346
1347        if error_with_attachments:
1348            changed = True
1349            comment = '%s\n%s\n' %(comment, error_with_attachments)
1350
1351        if self.parameters.email_triggers_workflow and (self.VERSION >= 0.11):
1352            if self.mail_workflow(tkt):
1353                changed = True
1354
1355        if changed:
1356            if self.parameters.dry_run:
1357                s = 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1358                self.logger.info(s)
1359
1360            else:
1361                tkt.save_changes(tkt['reporter'], comment)
1362
1363        if not spam:
1364            self.notify(tkt, True)
1365
1366
1367    def attach_attachments(self, message_parts, update=False):
1368        '''
1369        save any attachments as files in the ticket's directory
1370        '''
1371        self.logger.debug('function attach_attachments()')
1372
1373        if self.parameters.dry_run:
1374            self.logger.debug("DRY_RUN: no attachments attached to tickets")
1375            return ''
1376
1377        count = 0
1378
1379        ## Get Maxium attachment size
1380        #
1381        max_size = int(self.get_config('attachment', 'max_size'))
1382        status   = None
1383       
1384        for item in message_parts:
1385            ## Skip body parts
1386            #
1387            if not isinstance(item, tuple):
1388                continue
1389               
1390            (original, filename, part) = item
1391
1392            ## We have to determine the size so we use this temporary solution.
1393            #
1394            path, fd =  util.create_unique_file(os.path.join(self.parameters.tmpdir, 'email2trac_tmp.att'))
1395            text = part.get_payload(decode=1)
1396            if not text:
1397                text = '(None)'
1398            fd.write(text)
1399            fd.close()
1400
1401            ## get the file_size
1402            #
1403            stats = os.lstat(path)
1404            file_size = stats[ST_SIZE]
1405
1406            ## Check if the attachment size is allowed
1407            #
1408            if (max_size != -1) and (file_size > max_size):
1409                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1410                    %(status, original, file_size, max_size)
1411
1412                os.unlink(path)
1413                continue
1414            else:
1415                count = count + 1
1416                   
1417            ## Insert the attachment
1418            #
1419            fd = open(path, 'rb')
1420            if self.system == 'discussion':
1421                att = attachment.Attachment(self.env, 'discussion', 'topic/%s' % (self.id,))
1422            elif self.system == 'blog':
1423                att = attachment.Attachment(self.env, 'blog', '%s' % (self.id,))
1424            else:
1425                s = 'Attach %s to ticket %d' %(filename, self.id)
1426                self.print_unicode(s)
1427                att = attachment.Attachment(self.env, 'ticket', self.id)
1428 
1429            ## This will break the ticket_update system, the body_text is vaporized
1430            #  ;-(
1431            #
1432            if not update:
1433                att.author = self.author
1434                att.description = self.email_to_unicode('Added by email2trac')
1435
1436            try:
1437
1438                self.logger.debug('Insert atachment')
1439                att.insert(filename, fd, file_size)
1440
1441            except OSError, detail:
1442
1443                self.logger.info('%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail))
1444                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1445
1446            ## Remove the created temporary filename
1447            #
1448            fd.close()
1449            os.unlink(path)
1450
1451        ## return error
1452        #
1453        return status
1454
1455########## Fullblog functions  #################################################
1456
1457    def blog(self, msg, subject, id, params):
1458        """
1459        The blog create/update function
1460        """
1461        ## import the modules
1462        #
1463        from tracfullblog.core import FullBlogCore
1464        from tracfullblog.model import BlogPost, BlogComment
1465
1466        ## instantiate blog core
1467        #
1468        blog = FullBlogCore(self.env)
1469        req = Mock(authname='anonymous', perm=MockPerm(), args={})
1470
1471        ## parameters from the subject
1472        #
1473        params = self.str_to_dict((params or '').lstrip('?'))
1474
1475        ## preferably get the time from the email date header, or else
1476        #  use current date and time
1477        date = email.Utils.parsedate_tz(msg.get('date'))
1478        if date:
1479            dt = util.datefmt.to_datetime(email.Utils.mktime_tz(date), util.datefmt.utc)
1480        else:
1481            self.logger.warn("No valid date header found")
1482            dt = util.datefmt.to_datetime(None, util.datefmt.utc)
1483
1484        ## blog entry affected
1485        #
1486        self.id = id or util.datefmt.format_datetime(dt, "%Y%m%d%H%M%S", util.datefmt.utc)
1487
1488        ## check wether a blog post exists
1489        #
1490        post = BlogPost(self.env, self.id)
1491        force_update = self.properties.get('update', params.get('update'))
1492
1493        ## message parts
1494        #
1495        message_parts = self.get_message_parts(msg)
1496        message_parts = self.unique_attachment_names(message_parts)
1497
1498        if post.get_versions() and not force_update:
1499
1500            ## add comment to blog entry
1501            #
1502            comment = BlogComment(self.env, self.id)
1503            comment.author = self.properties.get('author', params.get('author', self.author))
1504            comment.comment = self.get_body_text(message_parts)
1505            comment.time = dt
1506
1507            if self.parameters.dry_run:
1508                self.logger.info('DRY-RUN: not adding comment for blog entry "%s"' % id)
1509                return
1510            warnings = blog.create_comment(req, comment)
1511
1512        else:
1513            ## create or update blog entry
1514            #
1515            post.author = self.properties.get('author', params.get('author', self.author))
1516            post.categories = self.properties.get('categories', params.get('categories', ''))
1517            post.title = subject.strip()
1518            post.publish_time = dt
1519            post.body = self.get_body_text(message_parts)
1520           
1521            if self.parameters.dry_run:
1522                self.logger.info('DRY-RUN: not creating blog entry "%s"' % post.title)
1523                return
1524            warnings = blog.create_post(req, post, self.author, u'Created by email2trac', False)
1525
1526        ## check for problems
1527        #
1528        if warnings:
1529            raise TracError(', '.join('blog:%s:%s' % (w[0], w[1]) for w in warnings))
1530       
1531        ## all seems well, attach attachments
1532        #
1533        self.attach_attachments(message_parts)
1534
1535
1536########## Discussion functions  ##############################################
1537
1538    def discussion_topic(self, content, subject):
1539
1540        ## Import modules.
1541        #
1542        from tracdiscussion.api import DiscussionApi
1543        from trac.util.datefmt import to_timestamp, utc
1544
1545        self.logger.debug('Creating a new topic in forum:', self.id)
1546
1547        ## Get dissussion API component.
1548        #
1549        api = self.env[DiscussionApi]
1550        context = self._create_context(content, subject)
1551
1552        ## Get forum for new topic.
1553        #
1554        forum = api.get_forum(context, self.id)
1555
1556        if not forum:
1557            self.logger.error("ERROR: Replied forum doesn't exist")
1558
1559        ## Prepare topic.
1560        #
1561        topic = {'forum' : forum['id'],
1562                 'subject' : context.subject,
1563                 'time': to_timestamp(datetime.now(utc)),
1564                 'author' : self.author,
1565                 'subscribers' : [self.email_addr],
1566                 'body' : self.get_body_text(context.content_parts)}
1567
1568        ## Add topic to DB and commit it.
1569        #
1570        self._add_topic(api, context, topic)
1571        self.db.commit()
1572
1573    def discussion_topic_reply(self, content, subject):
1574
1575        ## Import modules.
1576        #
1577        from tracdiscussion.api import DiscussionApi
1578        from trac.util.datefmt import to_timestamp, utc
1579
1580        self.logger.debug('Replying to discussion topic', self.id)
1581
1582        ## Get dissussion API component.
1583        #
1584        api = self.env[DiscussionApi]
1585        context = self._create_context(content, subject)
1586
1587        ## Get replied topic.
1588        #
1589        topic = api.get_topic(context, self.id)
1590
1591        if not topic:
1592            self.logger.error("ERROR: Replied topic doesn't exist")
1593
1594        ## Prepare message.
1595        #
1596        message = {'forum' : topic['forum'],
1597                   'topic' : topic['id'],
1598                   'replyto' : -1,
1599                   'time' : to_timestamp(datetime.now(utc)),
1600                   'author' : self.author,
1601                   'body' : self.get_body_text(context.content_parts)}
1602
1603        ## Add message to DB and commit it.
1604        #
1605        self._add_message(api, context, message)
1606        self.db.commit()
1607
1608    def discussion_message_reply(self, content, subject):
1609
1610        ## Import modules.
1611        #
1612        from tracdiscussion.api import DiscussionApi
1613        from trac.util.datefmt import to_timestamp, utc
1614
1615        self.logger.debug('Replying to discussion message', self.id)
1616
1617        ## Get dissussion API component.
1618        #
1619        api = self.env[DiscussionApi]
1620        context = self._create_context(content, subject)
1621
1622        ## Get replied message.
1623        #
1624        message = api.get_message(context, self.id)
1625
1626        if not message:
1627            self.logger.error("ERROR: Replied message doesn't exist")
1628
1629        ## Prepare message.
1630        #
1631        message = {'forum' : message['forum'],
1632                   'topic' : message['topic'],
1633                   'replyto' : message['id'],
1634                   'time' : to_timestamp(datetime.now(utc)),
1635                   'author' : self.author,
1636                   'body' : self.get_body_text(context.content_parts)}
1637
1638        ## Add message to DB and commit it.
1639        #
1640        self._add_message(api, context, message)
1641        self.db.commit()
1642
1643    def _create_context(self, content, subject):
1644
1645        ## Import modules.
1646        #
1647        from trac.mimeview import Context
1648        from trac.web.api import Request
1649        from trac.perm import PermissionCache
1650
1651        ## TODO: Read server base URL from config.
1652        #  Create request object to mockup context creation.
1653        #
1654        environ = {'SERVER_PORT' : 80,
1655                   'SERVER_NAME' : 'test',
1656                   'REQUEST_METHOD' : 'POST',
1657                   'wsgi.url_scheme' : 'http',
1658                   'wsgi.input' : sys.stdin}
1659        chrome =  {'links': {},
1660                   'scripts': [],
1661                   'ctxtnav': [],
1662                   'warnings': [],
1663                   'notices': []}
1664
1665        if self.env.base_url_for_redirect:
1666            environ['trac.base_url'] = self.env.base_url
1667
1668        req = Request(environ, None)
1669        req.chrome = chrome
1670        req.tz = 'missing'
1671        req.authname = self.author
1672        req.perm = PermissionCache(self.env, self.author)
1673        req.locale = None
1674
1675        ## Create and return context.
1676        #
1677        context = Context.from_request(req)
1678        context.realm = 'discussion-email2trac'
1679        context.cursor = self.db.cursor()
1680        context.content = content
1681        context.subject = subject
1682
1683        ## Read content parts from content.
1684        #
1685        context.content_parts = self.get_message_parts(content)
1686        context.content_parts = self.unique_attachment_names(
1687          context.content_parts)
1688
1689        return context
1690
1691    def _add_topic(self, api, context, topic):
1692        context.req.perm.assert_permission('DISCUSSION_APPEND')
1693
1694        ## Filter topic.
1695        #
1696        for discussion_filter in api.discussion_filters:
1697            accept, topic_or_error = discussion_filter.filter_topic(
1698              context, topic)
1699            if accept:
1700                topic = topic_or_error
1701            else:
1702                raise TracError(topic_or_error)
1703
1704        ## Add a new topic.
1705        #
1706        api.add_topic(context, topic)
1707
1708        ## Get inserted topic with new ID.
1709        #
1710        topic = api.get_topic_by_time(context, topic['time'])
1711
1712        ## Attach attachments.
1713        #
1714        self.id = topic['id']
1715        self.attach_attachments(context.content_parts, True)
1716
1717        ## Notify change listeners.
1718        #
1719        for listener in api.topic_change_listeners:
1720            listener.topic_created(context, topic)
1721
1722    def _add_message(self, api, context, message):
1723        context.req.perm.assert_permission('DISCUSSION_APPEND')
1724
1725        ## Filter message.
1726        #
1727        for discussion_filter in api.discussion_filters:
1728            accept, message_or_error = discussion_filter.filter_message(
1729              context, message)
1730            if accept:
1731                message = message_or_error
1732            else:
1733                raise TracError(message_or_error)
1734
1735        ## Add message.
1736        #
1737        api.add_message(context, message)
1738
1739        ## Get inserted message with new ID.
1740        #
1741        message = api.get_message_by_time(context, message['time'])
1742
1743        ## Attach attachments.
1744        #
1745
1746        self.attach_attachments(context.content_parts, True)
1747
1748        ## Notify change listeners.
1749        #
1750        for listener in api.message_change_listeners:
1751            listener.message_created(context, message)
1752
1753########## MAIN function  ######################################################
1754
1755    def parse(self, fp):
1756        """
1757        """
1758        self.logger.debug('Main function parse')
1759        global m
1760
1761        m = email.message_from_file(fp)
1762       
1763        if not m:
1764            self.logger.debug('This is not a valid email message format')
1765            return
1766           
1767        ## Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1768        #
1769        try:
1770            m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1771        except AttributeError, detail:
1772            pass
1773
1774        if self.parameters.debug:     # save the entire e-mail message text
1775            self.save_email_for_debug(m, self.parameters.project_name, True)
1776
1777        if self.VERSION <= 0.11:
1778            self.db = self.env.get_db_cnx()
1779        else:
1780            self.db = self.env.get_read_db()
1781
1782        self.get_sender_info(m)
1783
1784
1785        if self.parameters.white_list_file:
1786            self.acl_list_from_file(self.parameters.white_list_file, 'white_list')
1787
1788        if not ( self.email_header_acl('white_list', self.email_addr, True) or self.allow_registered_user ) :
1789
1790            self.logger.info('Message rejected : %s not in white list' %(self.email_addr))
1791            return False
1792
1793        if self.email_header_acl('black_list', self.email_addr, False):
1794            self.logger.info('Message rejected : %s in black list' %(self.email_addr))
1795            return False
1796
1797        if not self.email_header_acl('recipient_list', self.email_to_addrs, True):
1798            self.logger.info('Message rejected : %s not in recipient list' %(self.email_to_addrs))
1799            return False
1800
1801        ## If spam drop the message
1802        #
1803        if self.spam(m) == 'drop':
1804            return False
1805
1806        elif self.spam(m) == 'spam':
1807            spam_msg = True
1808        else:
1809            spam_msg = False
1810
1811        if not m['Subject']:
1812            subject  = 'No Subject'
1813        else:
1814            subject  = self.email_to_unicode(m['Subject'])
1815
1816        ## Bug in python logging module <2.6
1817        #
1818        self.logger.info('subject: %s' %repr(subject))
1819
1820        ## First try the new method and then fall back to old method
1821        #
1822        if not self.parse_delivered_to_field(m, subject, spam_msg):
1823            self.parse_subject_field(m, subject, spam_msg)
1824
1825    def parse_delivered_to_field(self, m, subject, spam_msg):
1826        """
1827        """
1828        self.logger.debug('function parse_delivered_to_field')
1829
1830        ## Ticket id is in Delivered-To Field. Trac notify must send this
1831        #  eg: is+390@surfsara.nl
1832       
1833        ## Only run if this parameter is se
1834        #
1835        if not self.parameters.recipient_delimiter:
1836            return False
1837
1838        try:
1839
1840            #print self.parameters.project_name
1841            self.logger.debug('Delivered To: %s' %m['Delivered-To'])
1842
1843            id = m['Delivered-To']
1844            id = id.split(self.parameters.recipient_delimiter)[1]
1845            id = id.split('@')[0]
1846
1847            self.logger.debug('\t Found ticket id: %s' %id)
1848
1849            if not self.ticket_update(m, id, spam_msg):
1850                return False
1851
1852        except KeyError, detail:
1853            pass
1854        except IndexError, detail:
1855            pass
1856
1857        return False
1858
1859    def parse_subject_field(self, m, subject, spam_msg):
1860        """
1861        """
1862        self.logger.debug('function parse_subject_header')
1863
1864        ## [hic] #1529: Re: LRZ
1865        #  [hic] #1529?owner=bas,priority=medium: Re: LRZ
1866        #
1867        ticket_regex = r'''
1868            (?P<new_fields>[#][?].*)
1869            |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?[^:]*)?:)
1870            '''
1871
1872        ## Check if  FullBlogPlugin is installed
1873        #
1874        blog_enabled = None
1875        blog_regex = ''
1876        if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1877            self.logger.debug('Trac BLOG support enabled')
1878            blog_enabled = True
1879            blog_regex = '''|(?P<blog>blog(?P<blog_params>[?][^:]*)?:(?P<blog_id>\S*))'''
1880
1881
1882        ## Check if DiscussionPlugin is installed
1883        #
1884        discussion_enabled = None
1885        discussion_regex = ''
1886        if self.get_config('components', 'tracdiscussion.api.discussionapi') in ['enabled']:
1887            self.logger.debug('Trac Discussion support enabled')
1888            discussion_enabled = True
1889            discussion_regex = r'''
1890            |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1891            |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1892            |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1893            '''
1894
1895
1896        regex_str = ticket_regex + blog_regex + discussion_regex
1897        SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1898
1899        ## Find out if this is a ticket, a blog or a discussion
1900        #
1901        result =  SYSTEM_RE.search(subject)
1902
1903        if result:
1904            ## update ticket + fields
1905            #
1906            if result.group('reply'):
1907                self.system = 'ticket'
1908
1909                ## Skip the last ':' character
1910                #
1911                if not self.ticket_update(m, result.group('reply')[:-1], spam_msg):
1912                    self.new_ticket(m, subject, spam_msg)
1913
1914            ## New ticket + fields
1915            #
1916            elif result.group('new_fields'):
1917                self.system = 'ticket'
1918                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1919
1920            if blog_enabled:
1921                if result.group('blog'):
1922                    self.system = 'blog'
1923                    self.blog(m, subject[result.end('blog'):], result.group('blog_id'), result.group('blog_params'))
1924
1925            if discussion_enabled:
1926                ## New topic.
1927                #
1928                if result.group('forum'):
1929                    self.system = 'discussion'
1930                    self.id = int(result.group('forum_id'))
1931                    self.discussion_topic(m, subject[result.end('forum'):])
1932
1933                ## Reply to topic.
1934                #
1935                elif result.group('topic'):
1936                    self.system = 'discussion'
1937                    self.id = int(result.group('topic_id'))
1938                    self.discussion_topic_reply(m, subject[result.end('topic'):])
1939
1940                ## Reply to topic message.
1941                #
1942                elif result.group('message'):
1943                    self.system = 'discussion'
1944                    self.id = int(result.group('message_id'))
1945                    self.discussion_message_reply(m, subject[result.end('message'):])
1946
1947        else:
1948
1949            self.system = 'ticket'
1950            (matched_id, subject) = self.ticket_update_by_subject(subject)
1951
1952            if matched_id:
1953
1954                if not self.ticket_update(m, matched_id, spam_msg):
1955                    self.new_ticket(m, subject, spam_msg)
1956
1957            else:
1958                ## No update by subject, so just create a new ticket
1959                #
1960                self.new_ticket(m, subject, spam_msg)
1961
1962
1963########## BODY TEXT functions  ###########################################################
1964
1965    def strip_signature(self, text):
1966        """
1967        Strip signature from message, inspired by Mailman software
1968        """
1969        self.logger.debug('function strip_signature: %s' %self.parameters.strip_signature_regex)
1970
1971        body = []
1972
1973        STRIP_RE = re.compile( self.parameters.strip_signature_regex )
1974        for line in text.splitlines():
1975
1976            match = STRIP_RE.match(line)
1977            if match:
1978                self.logger.debug('\t"%s "  matched, skiping rest of message' %line)
1979                break
1980
1981            body.append(line)
1982
1983        return ('\n'.join(body))
1984
1985    def reflow(self, text, delsp = 0):
1986        """
1987        Reflow the message based on the format="flowed" specification (RFC 3676)
1988        """
1989        flowedlines = []
1990        quotelevel = 0
1991        prevflowed = 0
1992
1993        for line in text.splitlines():
1994            from re import match
1995           
1996            ## Figure out the quote level and the content of the current line
1997            #
1998            m = match('(>*)( ?)(.*)', line)
1999            linequotelevel = len(m.group(1))
2000            line = m.group(3)
2001
2002            ## Determine whether this line is flowed
2003            #
2004            if line and line != '-- ' and line[-1] == ' ':
2005                flowed = 1
2006            else:
2007                flowed = 0
2008
2009            if flowed and delsp and line and line[-1] == ' ':
2010                line = line[:-1]
2011
2012            ## If the previous line is flowed, append this line to it
2013            #
2014            if prevflowed and line != '-- ' and linequotelevel == quotelevel:
2015                flowedlines[-1] += line
2016
2017            ## Otherwise, start a new line
2018            #
2019            else:
2020                flowedlines.append('>' * linequotelevel + line)
2021
2022            prevflowed = flowed
2023           
2024
2025        return '\n'.join(flowedlines)
2026
2027    def strip_quotes(self, text):
2028        """
2029        Strip quotes from message by Nicolas Mendoza
2030        """
2031        self.logger.debug('function strip_quotes: %s' %self.parameters.email_quote)
2032
2033        body = []
2034
2035        STRIP_RE = re.compile( self.parameters.email_quote )
2036
2037        for line in text.splitlines():
2038
2039            try:
2040
2041                match = STRIP_RE.match(line)
2042                if match:
2043                    self.logger.debug('\t"%s "  matched, skipping rest of message' %line)
2044                    continue
2045
2046            except UnicodeDecodeError:
2047
2048                tmp_line = self.email_to_unicode(line)
2049
2050                match = STRIP_RE.match(tmp_line)
2051                if match:
2052                    self.logger.debug('\t"%s "  matched, skipping rest of message' %line)
2053                    continue
2054               
2055            body.append(line)
2056
2057        return ('\n'.join(body))
2058
2059    def inline_properties(self, text):
2060        """
2061        Parse text if we use inline keywords to set ticket fields
2062        """
2063        self.logger.debug('function inline_properties')
2064
2065        properties = dict()
2066        body = list()
2067
2068        INLINE_EXP = re.compile('\s*[@]\s*(\w+)\s*:(.*)$')
2069
2070        for line in text.splitlines():
2071            match = INLINE_EXP.match(line)
2072            if match:
2073                keyword, value = match.groups()
2074
2075                if self.parameters.inline_properties_first_wins:
2076                    if keyword in self.properties.keys():
2077                        continue
2078
2079                self.properties[keyword] = value.strip()
2080                self.logger.debug('\tinline properties: %s : %s' %(keyword,value))
2081
2082            else:
2083                body.append(line)
2084               
2085        return '\n'.join(body)
2086
2087
2088    def wrap_text(self, text, replace_whitespace = False):
2089        """
2090        Will break a lines longer then given length into several small
2091        lines of size given length
2092        """
2093        import textwrap
2094
2095        LINESEPARATOR = '\n'
2096        reformat = ''
2097
2098        for s in text.split(LINESEPARATOR):
2099            tmp = textwrap.fill(s, self.parameters.use_textwrap)
2100            if tmp:
2101                reformat = '%s\n%s' %(reformat,tmp)
2102            else:
2103                reformat = '%s\n' %reformat
2104
2105        return reformat
2106
2107        # Python2.4 and higher
2108        #
2109        #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
2110        #
2111
2112########## EMAIL attachements functions ###########################################################
2113
2114    def inline_part(self, part):
2115        """
2116        """
2117        self.logger.debug('function inline_part()')
2118
2119        return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
2120
2121    def get_message_parts(self, msg, new_email=False):
2122        """
2123        parses the email message and returns a list of body parts and attachments
2124        body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
2125        """
2126        self.logger.debug('function get_message_parts()')
2127
2128        message_parts = list()
2129   
2130        ALTERNATIVE_MULTIPART = False
2131
2132        for part in msg.walk():
2133            content_maintype = part.get_content_maintype()
2134            content_type =  part.get_content_type()
2135
2136            self.logger.debug('\t Message part: Main-Type: %s' % content_maintype)
2137            self.logger.debug('\t Message part: Content-Type: %s' % content_type)
2138
2139            ## Check content type
2140            #
2141            if content_type in self.STRIP_CONTENT_TYPES:
2142                self.logger.debug("\t A %s attachment named '%s' was skipped" %(content_type, part.get_filename()))
2143                continue
2144
2145            ## Catch some mulitpart execptions
2146            #
2147            if content_type == 'multipart/alternative':
2148                ALTERNATIVE_MULTIPART = True
2149                continue
2150
2151            ## Skip multipart containers
2152            #
2153            if content_maintype == 'multipart':
2154                self.logger.debug("\t Skipping multipart container")
2155                continue
2156           
2157            ## Check if this is an inline part. It's inline if there is co Cont-Disp header,
2158            #  or if there is one and it says "inline"
2159            #
2160            inline = self.inline_part(part)
2161
2162            ## Drop HTML message
2163            #
2164            if ALTERNATIVE_MULTIPART and self.parameters.drop_alternative_html_version:
2165
2166                if content_type == 'text/html':
2167                    self.logger.debug('\t Skipping alternative HTML message')
2168                    ALTERNATIVE_MULTIPART = False
2169                    continue
2170
2171
2172            #if self.VERSION < 1.0:
2173            #    filename = part.get_filename()
2174
2175            ## convert 7 bit filename to 8 bit unicode
2176            #
2177            raw_filename = part.get_filename()
2178            filename = self.email_to_unicode(raw_filename);
2179
2180            s = '\t unicode filename: %s' %(filename)
2181            self.print_unicode(s)
2182            self.logger.debug('\t raw filename: %s' %repr(raw_filename))
2183
2184            if self.VERSION < 1.0:
2185                filename = self.check_filename_length(filename)
2186
2187            ## Save all non plain text message as attachment
2188            #
2189            if not content_type in ['text/plain']:
2190
2191                message_parts.append( (filename, part) )
2192
2193                ## We only convert html messages
2194                #
2195                if not content_type == 'text/html':
2196                    self.logger.debug('\t Appending %s (%s)' %(repr(filename), content_type))
2197                    continue
2198
2199
2200            ## We have an text or html message
2201            #
2202            if not inline:
2203                    self.logger.debug('\t Appending %s (%s), not an inline messsage part' %(repr(filename), content_type))
2204                    message_parts.append( (filename, part) )
2205                    continue
2206               
2207            ## Try to decode message part. We have a html or plain text messafe
2208            #
2209            body_text = part.get_payload(decode=1)
2210            if not body_text:           
2211                body_text = part.get_payload(decode=0)
2212
2213            ## Try to convert html message
2214            #
2215            if content_type == 'text/html':
2216
2217                body_text = self.html_2_txt(body_text)
2218                if not body_text:
2219                    continue
2220
2221            format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
2222            delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
2223
2224            if self.parameters.reflow and not self.parameters.verbatim_format and format == 'flowed':
2225                body_text = self.reflow(body_text, delsp == 'yes')
2226
2227            if new_email and self.parameters.only_strip_on_update:
2228                self.logger.debug('Skip signature/quote stripping for new messages')
2229            else:
2230                if self.parameters.strip_signature:
2231                    body_text = self.strip_signature(body_text)
2232
2233                if self.parameters.strip_quotes:
2234                    body_text = self.strip_quotes(body_text)
2235
2236            if self.parameters.inline_properties:
2237                body_text = self.inline_properties(body_text)
2238
2239            if self.parameters.use_textwrap:
2240                body_text = self.wrap_text(body_text)
2241
2242            ## Get contents charset (iso-8859-15 if not defined in mail headers)
2243            #
2244            charset = part.get_content_charset()
2245            if not charset:
2246                charset = 'iso-8859-15'
2247
2248            try:
2249                ubody_text = unicode(body_text, charset)
2250
2251            except UnicodeError, detail:
2252                ubody_text = unicode(body_text, 'iso-8859-15')
2253
2254            except LookupError, detail:
2255                ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
2256
2257            if self.parameters.verbatim_format:
2258                message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
2259            else:
2260                message_parts.append('%s' %ubody_text)
2261
2262        return message_parts
2263       
2264    def unique_attachment_names(self, message_parts):
2265        """
2266        Make sure we have unique names attachments:
2267          - check if it contains illegal characters
2268          - Rename "None" filenames to "untitled-part"
2269        """
2270        self.logger.debug('function unique_attachment_names()')
2271        renamed_parts = []
2272        attachment_names = set()
2273
2274        for item in message_parts:
2275           
2276            ## If not an attachment, leave it alone
2277            #
2278            if not isinstance(item, tuple):
2279                renamed_parts.append(item)
2280                continue
2281               
2282            (filename, part) = item
2283
2284            ## If filename = None, use a default one
2285            #
2286            if filename in [ 'None']:
2287                filename = 'untitled-part'
2288                self.logger.info('\t Rename filename "None" to: %s' %filename)
2289
2290                ## Guess the extension from the content type, use non strict mode
2291                #  some additional non-standard but commonly used MIME types
2292                #  are also recognized
2293                #
2294                ext = mimetypes.guess_extension(part.get_content_type(), False)
2295                if not ext:
2296                    ext = '.bin'
2297
2298                filename = '%s%s' % (filename, ext)
2299
2300            ## Discard relative paths for windows/unix in attachment names
2301            #
2302            filename = filename.replace('\\', '_')
2303            filename = filename.replace('/', '_')
2304
2305            ## remove linefeed char
2306            #
2307            for forbidden_char in ['\r', '\n']:
2308                filename = filename.replace(forbidden_char,'')
2309
2310            ## We try to normalize the filename to utf-8 NFC if we can.
2311            #  Files uploaded from OS X might be in NFD.
2312            #  Check python version and then try it
2313            #
2314            #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
2315            #   try:
2316            #       filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
2317            #   except TypeError:
2318            #       pass
2319
2320            ## Make the filename unique for this ticket
2321            #
2322            num = 0
2323            unique_filename = filename
2324            dummy_filename, ext = os.path.splitext(filename)
2325
2326            while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
2327                num += 1
2328                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
2329               
2330            s = '\t Attachment with filename %s will be saved as %s' % (filename, unique_filename)
2331            self.print_unicode(s)
2332
2333            attachment_names.add(unique_filename)
2334
2335            renamed_parts.append((filename, unique_filename, part))
2336   
2337        return renamed_parts
2338           
2339           
2340    def attachment_exists(self, filename):
2341
2342        self.logger.debug("function attachment_exists")
2343
2344        s = '\t check if attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
2345        self.print_unicode(s)
2346
2347        ## Do we have a valid ticket id
2348        #
2349        if not self.id:
2350            return False
2351
2352        try:
2353            if self.system == 'discussion':
2354
2355                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s' % (self.id,), filename)
2356
2357            elif self.system == 'blog':
2358
2359                att = attachment.Attachment(self.env, 'blog', '%s' % (self.id,), filename)
2360
2361            else:
2362
2363                att = attachment.Attachment(self.env, 'ticket', self.id, filename)
2364
2365            return True
2366
2367        except attachment.ResourceNotFound:
2368
2369            return False
2370
2371########## TRAC Ticket Text ###########################################################
2372           
2373    def get_body_text(self, message_parts):
2374        """
2375        """
2376        self.logger.debug('function get_body_text()')
2377
2378        body_text = []
2379       
2380        for part in message_parts:
2381       
2382            ## Plain text part, append it
2383            #
2384            if not isinstance(part, tuple):
2385                body_text.extend(part.strip().splitlines())
2386                body_text.append("")
2387                continue
2388
2389            (original, filename, part) = part
2390            inline = self.inline_part(part)
2391
2392            ## Skip generation of attachment link if html is converted to text
2393            #
2394            if part.get_content_type() == 'text/html' and self.parameters.html2text_cmd and inline:
2395                s = 'Skipping attachment link for html part: %s' %(filename)
2396                self.print_unicode(s)
2397                continue
2398           
2399            if part.get_content_maintype() == 'image' and inline:
2400
2401                if self.system != 'discussion':
2402                    s = 'wiki image link for: %s' %(filename)
2403                    self.print_unicode(s)
2404                    body_text.append('[[Image(%s)]]' % filename)
2405
2406                body_text.append("")
2407
2408            else:
2409
2410                if self.system != 'discussion':
2411
2412                    s = 'wiki attachment link for: %s' %(filename)
2413                    self.print_unicode(s)
2414                    body_text.append('[attachment:"%s"]' % filename)
2415
2416                body_text.append("")
2417
2418        ## Convert list body_texts to string
2419        #
2420        body_text = '\r\n'.join(body_text)
2421        return body_text
2422
2423    def html_mailto_link(self, subject):
2424        """
2425        This function returns a HTML mailto tag with the ticket id and author email address
2426        """
2427        self.logger.debug("function html_mailto_link")
2428
2429        if not self.author:
2430            author = self.email_addr
2431        else:   
2432            author = self.author
2433
2434        if not self.parameters.mailto_cc:
2435            self.parameters.mailto_cc = ''
2436           
2437        ## Bug in urllib.quote function
2438        #
2439        if isinstance(subject, unicode):
2440            subject = subject.encode('utf-8')
2441
2442        ## use urllib to escape the chars
2443        #
2444        s = '%s?Subject=%s&cc=%s' %(
2445               urllib.quote(self.email_addr),
2446               urllib.quote('Re: #%s: %s' %(self.id, subject)),
2447               urllib.quote(self.parameters.mailto_cc)
2448               )
2449
2450        if self.VERSION in [ 0.10 ]:
2451            s = '\r\n{{{\r\n#!html\r\n<a\r\n href="mailto:%s">Reply to: %s\r\n</a>\r\n}}}\r\n' %(s, author)
2452        else:
2453            s = '[mailto:"%s" Reply to: %s]' %(s, author)
2454
2455        self.logger.debug("\tmailto link %s" %s)
2456        return s
2457
2458########## TRAC notify section ###########################################################
2459
2460    def notify(self, tkt, new=True, modtime=0):
2461        """
2462        A wrapper for the TRAC notify function. So we can use templates
2463        """
2464        self.logger.debug('function notify()')
2465
2466
2467        class Email2TracNotifyEmail(TicketNotifyEmail):
2468
2469            def __init__(self, env):
2470                TicketNotifyEmail.__init__(self, env)
2471                self.email2trac_notify_reporter = None
2472                self.email2trac_replyto = None
2473
2474            def send(self, torcpts, ccrcpts):
2475                #print 'Yes this works'
2476                dest = self.reporter or 'anonymous'
2477                hdrs = {}
2478                hdrs['Message-ID'] = self.get_message_id(dest, self.modtime)
2479                hdrs['X-Trac-Ticket-ID'] = str(self.ticket.id)
2480                hdrs['X-Trac-Ticket-URL'] = self.data['ticket']['link']
2481                if not self.newticket:
2482                    msgid = self.get_message_id(dest)
2483                    hdrs['In-Reply-To'] = msgid
2484                    hdrs['References'] = msgid
2485
2486
2487                if self.email2trac_notify_reporter:
2488                    if not self.email2trac_notify_reporter in torcpts:
2489                        torcpts.append(self.email2trac_notify_reporter)
2490
2491                if self.email2trac_replyto:
2492                    # use to rewrite reply to
2493                    # hdrs does not work, multiple reply addresses
2494                    #hdrs['Reply-To'] = 'bas.van.der.vlies@gmail.com'
2495                    self.replyto_email = self.email2trac_replyto
2496       
2497                NotifyEmail.send(self, torcpts, ccrcpts, hdrs)
2498
2499        if self.parameters.dry_run  :
2500                self.logger.info('DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter'])
2501                return
2502
2503        try:
2504
2505
2506            ## create false {abs_}href properties, to trick Notify()
2507            #
2508            if self.VERSION in [0.10]:
2509                self.env.abs_href = Href(self.get_config('project', 'url'))
2510                self.env.href = Href(self.get_config('project', 'url'))
2511
2512            tn = Email2TracNotifyEmail(self.env)
2513
2514            ## additionally append sender (regardeless of settings in trac.ini)
2515            #
2516            if self.parameters.notify_reporter:
2517
2518                self.logger.debug('\t Notify reporter set')
2519
2520                if not self.email_header_acl('notify_reporter_black_list', self.email_addr, False):
2521                    tn.email2trac_notify_reporter = self.email_addr
2522
2523            if self.parameters.notify_replyto_rewrite:
2524
2525                self.logger.debug('\t Notify replyto rewrite set')
2526
2527                action, value = self.parameters.notify_replyto_rewrite.split(':')
2528
2529                if action in ['use_mail_domain']:
2530                    self.logger.debug('\t\t use_mail_domain:%s' %value)
2531                    tn.email2trac_replyto = '%s@%s' %(self.id, value)
2532
2533                elif action in ['use_trac_smtp_replyto']:
2534                    self.logger.debug('\t\t use_trac_smtp_replyto delimiter:%s' %value)
2535                    dummy = self.smtp_replyto.split('@')
2536                    if len(dummy) > 1:
2537                        tn.email2trac_replyto = '%s%s%s@%s' %(dummy[0], value, self.id, dummy[1])
2538                    else:
2539                        tn.email2trac_replyto = '%s%s%s' %(dummy[0], value, self.id)
2540
2541            if self.parameters.alternate_notify_template:
2542
2543                if self.VERSION >= 0.11:
2544
2545                    from trac.web.chrome import Chrome
2546
2547                    if  self.parameters.alternate_notify_template_update and not new:
2548                        tn.template_name = self.parameters.alternate_notify_template_update
2549                    else:
2550                        tn.template_name = self.parameters.alternate_notify_template
2551
2552                    tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
2553                       
2554                else:
2555
2556                    tn.template_name = self.parameters.alternate_notify_template
2557
2558            tn.notify(tkt, new, modtime)
2559
2560        except Exception, e:
2561            self.logger.error('Failure sending notification on creation of ticket #%s: %s' %(self.id, e))
2562
2563########## END Class Definition  ########################################################
2564
2565
2566########## Parse Config File  ###########################################################
2567
2568def ReadConfig(file, name):
2569    """
2570    Parse the config file
2571    """
2572    if not os.path.isfile(file):
2573        print 'File %s does not exist' %file
2574        sys.exit(1)
2575
2576    config = trac_config.Configuration(file)
2577   
2578    parentdir = config.get('DEFAULT', 'parentdir')
2579    sections = config.sections()
2580
2581    ## use some trac internals to get the defaults
2582    #
2583    tmp = config.parser.defaults()
2584    project =  SaraDict()
2585
2586    for option, value in tmp.items():
2587        try:
2588            project[option] = int(value)
2589        except ValueError:
2590            project[option] = value
2591
2592    if name:
2593        if name in sections:
2594            project =  SaraDict()
2595            for option, value in  config.options(name):
2596                try:
2597                    project[option] = int(value)
2598                except ValueError:
2599                    project[option] = value
2600
2601        elif not parentdir:
2602            print "Not a valid project name: %s, valid names are: %s" %(name, sections)
2603            print "or set parentdir in the [DEFAULT] section"
2604            sys.exit(1)
2605
2606    ## If parentdir then set project dir to parentdir + name
2607    #
2608    if not project.has_key('project'):
2609        if not parentdir:
2610            print "You must set project or parentdir in your configuration file"
2611            sys.exit(1)
2612        elif not name:
2613            print "You must configure a  project section in your configuration file"
2614        else:
2615            project['project'] = os.path.join(parentdir, name)
2616
2617    ##
2618    # Save the project name
2619    #
2620    project['project_name'] = os.path.basename(project['project'])
2621
2622    return project
2623
2624########## Setup Logging ###############################################################
2625
2626def setup_log(parameters, project_name, interactive=None):
2627    """
2628    Setup logging
2629
2630    Note for log format the usage of `$(...)s` instead of `%(...)s` as the latter form
2631    would be interpreted by the ConfigParser itself.
2632    """
2633    logger = logging.getLogger('email2trac %s' %project_name)
2634
2635    if interactive:
2636        parameters.log_type = 'stderr'
2637
2638    if not parameters.log_type:
2639        if sys.platform in ['win32', 'cygwin']:
2640            parameters.log_type = 'eventlog'
2641        else:
2642            parameters.log_type = 'syslog'
2643
2644    if parameters.log_type == 'file':
2645
2646        if not parameters.log_file:
2647            parameters.log_file = 'email2trac.log'
2648
2649        if not os.path.isabs(parameters.log_file):
2650            parameters.log_file = os.path.join(tempfile.gettempdir(), parameters.log_file)
2651
2652        log_handler = logging.FileHandler(parameters.log_file)
2653
2654    elif parameters.log_type in ('winlog', 'eventlog', 'nteventlog'):
2655        ## Requires win32 extensions
2656        #
2657        logid = "email2trac"
2658        log_handler = logging.handlers.NTEventLogHandler(logid, logtype='Application')
2659
2660    elif parameters.log_type in ('syslog', 'unix'):
2661        log_handler = logging.handlers.SysLogHandler('/dev/log')
2662
2663    elif parameters.log_type in ('stderr'):
2664        log_handler = logging.StreamHandler(sys.stderr)
2665
2666    else:
2667        log_handler = logging.handlers.BufferingHandler(0)
2668
2669    if parameters.log_format:
2670        parameters.log_format = parameters.log_format.replace('$(', '%(')
2671    else:
2672        parameters.log_format = '%(name)s: %(message)s'
2673        if parameters.log_type in ('file', 'stderr'):
2674            parameters.log_format = '%(asctime)s ' + parameters.log_format
2675
2676    log_formatter = logging.Formatter(parameters.log_format)
2677    log_handler.setFormatter(log_formatter)
2678    logger.addHandler(log_handler)
2679
2680    if (parameters.log_level in ['DEBUG', 'ALL']) or (parameters.debug > 0):
2681        logger.setLevel(logging.DEBUG)
2682        parameters.debug = 1
2683
2684    elif parameters.log_level in ['INFO'] or parameters.verbose:
2685        logger.setLevel(logging.INFO)
2686
2687    elif parameters.log_level in ['WARNING']:
2688        logger.setLevel(logging.WARNING)
2689
2690    elif parameters.log_level in ['ERROR']:
2691        logger.setLevel(logging.ERROR)
2692
2693    elif parameters.log_level in ['CRITICAL']:
2694        logger.setLevel(logging.CRITICAL)
2695
2696    else:
2697        logger.setLevel(logging.INFO)
2698
2699    return logger
2700
2701########## Own TicketNotifyEmail class ###############################################################
2702
2703if __name__ == '__main__':
2704    ## Default config file
2705    #
2706    agilo = False
2707    bh_product = None
2708    configfile = '@email2trac_conf@'
2709    project = ''
2710    component = ''
2711    ticket_prefix = 'default'
2712    dry_run = None
2713    verbose = None
2714    debug_interactive = None
2715    virtualenv = '@virtualenv@'
2716
2717    SHORT_OPT = 'AB:cdE:hf:np:t:v'
2718    LONG_OPT  =  ['agilo', 'bh_product=', 'component=', 'debug',
2719                  'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=',
2720                  'virtualenv=', 'verbose']
2721
2722    try:
2723        opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2724    except getopt.error,detail:
2725        print __doc__
2726        print detail
2727        sys.exit(1)
2728   
2729    project_name = None
2730    for opt,value in opts:
2731        if opt in [ '-h', '--help']:
2732            print __doc__
2733            sys.exit(0)
2734        elif opt in ['-A', '--agilo']:
2735            agilo = True
2736        elif opt in ['-B', '--bh_product']:
2737            bh_product = value
2738        elif opt in ['-c', '--component']:
2739            component = value
2740        elif opt in ['-d', '--debug']:
2741            debug_interactive = 1
2742        elif opt in ['-E', '--virtualenv']:
2743            virtualenv = value
2744        elif opt in ['-f', '--file']:
2745            configfile = value
2746        elif opt in ['-n', '--dry-run']:
2747            dry_run = True
2748        elif opt in ['-p', '--project']:
2749            project_name = value
2750        elif opt in ['-t', '--ticket_prefix']:
2751            ticket_prefix = value
2752        elif opt in ['-v', '--verbose']:
2753            verbose = True
2754
2755    if virtualenv and os.path.exists(virtualenv):
2756        activate_this = os.path.join(virtualenv, 'bin/activate_this.py')
2757        if os.path.exists(activate_this):
2758            execfile(activate_this, dict(__file__=activate_this))
2759
2760    try:
2761        from trac import __version__ as trac_version
2762        from trac import config as trac_config
2763
2764    except ImportError, detail:
2765        print detail
2766        print "Can not find a a valid trac installation, solutions could be:"
2767        print "\tset PYTHONPATH"
2768        print "\tuse the --virtualenv <dir> option"
2769        sys.exit(1)
2770   
2771    settings = ReadConfig(configfile, project_name)
2772
2773    ## The default prefix for ticket values in email2trac.conf
2774    #
2775    settings.ticket_prefix = ticket_prefix
2776    settings.dry_run = dry_run
2777    settings.verbose = verbose
2778
2779    if bh_product:
2780        settings.bh_product = bh_product
2781
2782    if not settings.debug and debug_interactive:
2783        settings.debug = debug_interactive
2784
2785    if not settings.project:
2786        print __doc__
2787        print 'No Trac project is defined in the email2trac config file.'
2788        sys.exit(1)
2789
2790    logger = setup_log(settings, os.path.basename(settings.project), debug_interactive)
2791   
2792    if component:
2793        settings['component'] = component
2794
2795    ## We are only interested in the major versions
2796    # 0.12.3 --> 0.12
2797    # 1.0.2  --> 1.0
2798    #
2799    l = trac_version.split('.')
2800    version = '.'.join(l[0:2])
2801
2802    logger.debug("Found trac version: %s" %(version))
2803   
2804    try:
2805        if version == '0.10':
2806            from trac import attachment
2807            from trac.env import Environment
2808            from trac.ticket import Ticket
2809            from trac.web.href import Href
2810            from trac import util
2811            from trac.ticket.web_ui import TicketModule
2812
2813            #
2814            # return  util.text.to_unicode(str)
2815            #
2816            # see http://projects.edgewall.com/trac/changeset/2799
2817            from trac.ticket.notification import TicketNotifyEmail
2818            from trac import config as trac_config
2819            from trac.core import TracError
2820
2821        elif version in ['0.11', '0.12', '0.13', '1.0', '1.1']:
2822            from trac import attachment
2823            from trac import config as trac_config
2824            from trac import util
2825            from trac.core import TracError
2826            from trac.env import Environment
2827            from trac.perm import PermissionSystem
2828            from trac.perm import PermissionCache
2829            from trac.test import Mock, MockPerm
2830            from trac.ticket.api import TicketSystem
2831            from trac.ticket.web_ui import TicketModule
2832            from trac.web.href import Href
2833
2834            try:
2835                import pkg_resources
2836                pkg = pkg_resources.get_distribution('BloodhoundMultiProduct')
2837                bloodhound = pkg.version.split()[:2]
2838            except pkg_resources.DistributionNotFound:
2839                # assume no bloodhound
2840                bloodhound = None
2841
2842            if bloodhound:
2843                from multiproduct.env import Environment, ProductEnvironment
2844                from multiproduct.ticket.web_ui import (ProductTicketModule
2845                                                        as TicketModule)
2846                logger.debug("Found Bloodhound Distribution")
2847
2848                if not settings.bh_product:
2849                    print __doc__
2850                    print 'No Bloodhound project defined (bh_project) in section:%s email2trac config file.' %(settings.project)
2851                    sys.exit(1)
2852
2853            if agilo:
2854
2855                try:
2856
2857                    from agilo.ticket.model import AgiloTicket as Ticket
2858
2859                except ImportError, detail:
2860
2861                    try:
2862                        from agilo.ticket.model import Ticket
2863                    except ImportError, detail:
2864                        logger.error('Could not find Trac  Agilo environemnt')
2865                        sys.exit(0)
2866
2867            else:
2868
2869                from trac.ticket import Ticket
2870
2871            #
2872            # return  util.text.to_unicode(str)
2873            #
2874            # see http://projects.edgewall.com/trac/changeset/2799
2875            from trac.ticket.notification import TicketNotifyEmail
2876            from trac.notification import NotifyEmail
2877
2878        else:
2879            logger.error('TRAC version %s is not supported' %version)
2880            sys.exit(0)
2881
2882        ## Must be set before environment is created
2883        #
2884        if settings.has_key('python_egg_cache'):
2885            python_egg_cache = str(settings['python_egg_cache'])
2886            os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2887
2888        if settings.debug > 0:
2889            logger.debug('Loading environment %s', settings.project)
2890
2891        try:
2892            env = Environment(settings['project'], create=0)
2893
2894            if bloodhound:
2895                ## possibly overkill testing if the multiproduct schema is a
2896                # new enough version
2897                #
2898                from multiproduct.env import MultiProductSystem
2899                mps = MultiProductSystem(env)
2900                if mps.get_version() > 4:
2901                    try:
2902                        env = ProductEnvironment(env, settings.bh_product, create=0)
2903                    except LookupError:
2904                        logger.error('%s is not a valid Bloodhound' %settings.bh_product)
2905                        sys.exit(0)
2906               
2907        except IOError, detail:
2908            logger.error("trac error: %s" %detail)
2909            sys.exit(0)
2910        except TracError, detail:
2911            logger.error("trac error: %s" %detail)
2912            sys.exit(0)
2913
2914        tktparser = TicketEmailParser(env, settings, logger, float(version))
2915        tktparser.parse(sys.stdin)
2916
2917    ## Catch all errors and use the logging module
2918    #
2919    except Exception, error:
2920
2921        etype, evalue, etb = sys.exc_info()
2922        for e in traceback.format_exception(etype, evalue, etb):
2923            logger.critical(e)
2924
2925        if m:
2926            tktparser.save_email_for_debug(m, settings.project_name, True)
2927
2928        sys.exit(1)
2929
2930# EOB
Note: See TracBrowser for help on using the repository browser.