Ticket #296: email2trac_custom.py

File email2trac_custom.py, 85.1 KB (added by steverweber@…, 12 years ago)

custom email2trac that adds regex for strip_signature

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